@qontinui/ui-bridge-native 0.6.10 → 0.6.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -11,6 +11,179 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
11
11
  throw Error('Dynamic require of "' + x + '" is not supported');
12
12
  });
13
13
 
14
+ // src/core/vision-fields.ts
15
+ var INTERACTIVE_HANDLER_NAMES = /* @__PURE__ */ new Set([
16
+ "onPress",
17
+ "onPressIn",
18
+ "onPressOut",
19
+ "onLongPress",
20
+ "onChangeText",
21
+ "onValueChange",
22
+ "onChange",
23
+ "onSubmitEditing",
24
+ "onClick",
25
+ "onTouchStart",
26
+ "onTouchEnd"
27
+ ]);
28
+ var INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
29
+ "button",
30
+ "input",
31
+ "switch",
32
+ "checkbox",
33
+ "radio",
34
+ "touchable",
35
+ "pressable",
36
+ "listItem"
37
+ ]);
38
+ function roleForType(type) {
39
+ switch (type) {
40
+ case "button":
41
+ case "touchable":
42
+ case "pressable":
43
+ return "button";
44
+ case "input":
45
+ return "textbox";
46
+ case "text":
47
+ return "text";
48
+ case "switch":
49
+ return "switch";
50
+ case "checkbox":
51
+ return "checkbox";
52
+ case "radio":
53
+ return "radio";
54
+ case "image":
55
+ return "img";
56
+ case "list":
57
+ return "list";
58
+ case "listItem":
59
+ return "listitem";
60
+ case "modal":
61
+ return "dialog";
62
+ case "scroll":
63
+ case "view":
64
+ case "custom":
65
+ default:
66
+ return void 0;
67
+ }
68
+ }
69
+ function deriveInteractable(type, handlerNames) {
70
+ if (INTERACTIVE_TYPES.has(type)) return true;
71
+ if (handlerNames) {
72
+ for (const name of handlerNames) {
73
+ if (INTERACTIVE_HANDLER_NAMES.has(name)) return true;
74
+ }
75
+ }
76
+ return false;
77
+ }
78
+ function deriveText(state, label) {
79
+ const candidates = [state.value, state.textContent, label];
80
+ for (const c of candidates) {
81
+ if (typeof c === "string") {
82
+ const trimmed = c.trim();
83
+ if (trimmed.length > 0) return trimmed;
84
+ }
85
+ }
86
+ return void 0;
87
+ }
88
+ var NAMED_COLORS = {
89
+ black: { r: 0, g: 0, b: 0 },
90
+ white: { r: 255, g: 255, b: 255 },
91
+ red: { r: 255, g: 0, b: 0 },
92
+ green: { r: 0, g: 128, b: 0 },
93
+ blue: { r: 0, g: 0, b: 255 },
94
+ gray: { r: 128, g: 128, b: 128 },
95
+ grey: { r: 128, g: 128, b: 128 },
96
+ silver: { r: 192, g: 192, b: 192 },
97
+ transparent: { r: 0, g: 0, b: 0 },
98
+ orange: { r: 255, g: 165, b: 0 },
99
+ yellow: { r: 255, g: 255, b: 0 },
100
+ purple: { r: 128, g: 0, b: 128 },
101
+ navy: { r: 0, g: 0, b: 128 }
102
+ };
103
+ var clamp255 = (n) => Math.max(0, Math.min(255, Math.round(n)));
104
+ function parseColorToRgb(input) {
105
+ if (typeof input !== "string") return void 0;
106
+ const raw = input.trim().toLowerCase();
107
+ if (raw.length === 0) return void 0;
108
+ if (Object.prototype.hasOwnProperty.call(NAMED_COLORS, raw)) {
109
+ return NAMED_COLORS[raw];
110
+ }
111
+ if (raw.startsWith("#")) {
112
+ const hex = raw.slice(1);
113
+ if (hex.length === 3 || hex.length === 4) {
114
+ const r = parseInt(hex[0] + hex[0], 16);
115
+ const g = parseInt(hex[1] + hex[1], 16);
116
+ const b = parseInt(hex[2] + hex[2], 16);
117
+ if ([r, g, b].every((v) => Number.isFinite(v))) return { r, g, b };
118
+ return void 0;
119
+ }
120
+ if (hex.length === 6 || hex.length === 8) {
121
+ const r = parseInt(hex.slice(0, 2), 16);
122
+ const g = parseInt(hex.slice(2, 4), 16);
123
+ const b = parseInt(hex.slice(4, 6), 16);
124
+ if ([r, g, b].every((v) => Number.isFinite(v))) return { r, g, b };
125
+ return void 0;
126
+ }
127
+ return void 0;
128
+ }
129
+ const rgbMatch = raw.match(/^rgba?\(([^)]+)\)$/);
130
+ if (rgbMatch) {
131
+ const parts = rgbMatch[1].split(",").map((p) => p.trim());
132
+ if (parts.length < 3) return void 0;
133
+ const channel = (p) => {
134
+ if (p.endsWith("%")) {
135
+ const pct = parseFloat(p.slice(0, -1));
136
+ if (!Number.isFinite(pct)) return void 0;
137
+ return clamp255(pct / 100 * 255);
138
+ }
139
+ const n = parseFloat(p);
140
+ if (!Number.isFinite(n)) return void 0;
141
+ return clamp255(n);
142
+ };
143
+ const r = channel(parts[0]);
144
+ const g = channel(parts[1]);
145
+ const b = channel(parts[2]);
146
+ if (r === void 0 || g === void 0 || b === void 0) return void 0;
147
+ return { r, g, b };
148
+ }
149
+ return void 0;
150
+ }
151
+ function parseFontSizePx(input) {
152
+ if (typeof input === "number") {
153
+ return Number.isFinite(input) && input > 0 ? input : void 0;
154
+ }
155
+ if (typeof input === "string") {
156
+ const m = input.trim().match(/^(-?\d*\.?\d+)/);
157
+ if (m) {
158
+ const n = parseFloat(m[1]);
159
+ return Number.isFinite(n) && n > 0 ? n : void 0;
160
+ }
161
+ }
162
+ return void 0;
163
+ }
164
+ function projectVisionFields(args) {
165
+ const { type, label, handlerNames, state, flatStyle } = args;
166
+ const fields = {
167
+ interactable: deriveInteractable(type, handlerNames)
168
+ };
169
+ const text = deriveText(state, label);
170
+ if (text !== void 0) fields.text = text;
171
+ const role = roleForType(type);
172
+ if (role !== void 0) fields.role = role;
173
+ if (flatStyle) {
174
+ const fg = parseColorToRgb(flatStyle.color);
175
+ if (fg !== void 0) fields.fg_color = fg;
176
+ const bg = parseColorToRgb(flatStyle.backgroundColor);
177
+ if (bg !== void 0) fields.bg_color = bg;
178
+ const size = parseFontSizePx(flatStyle.fontSize);
179
+ if (size !== void 0) fields.font_size_px = size;
180
+ if (typeof flatStyle.fontFamily === "string" && flatStyle.fontFamily.trim().length > 0) {
181
+ fields.font_family = flatStyle.fontFamily;
182
+ }
183
+ }
184
+ return fields;
185
+ }
186
+
14
187
  // src/core/registry.ts
15
188
  function extractHandlerNames(props) {
16
189
  if (!props) return [];
@@ -20,6 +193,25 @@ function matchesCurrentRoute(registrationRoute, currentRoute) {
20
193
  if (registrationRoute == null || registrationRoute === "") return true;
21
194
  return registrationRoute === currentRoute;
22
195
  }
196
+ function projectBbox(state, visibility, pixelRatio = 1) {
197
+ if (visibility !== "visible") return void 0;
198
+ const layout = state.layout;
199
+ if (!layout) return void 0;
200
+ const ratio = pixelRatio > 0 && Number.isFinite(pixelRatio) ? pixelRatio : 1;
201
+ const toPx = (v) => Math.max(0, Math.round(v * ratio));
202
+ const hasPage = typeof layout.pageX === "number" && typeof layout.pageY === "number" && Number.isFinite(layout.pageX) && Number.isFinite(layout.pageY);
203
+ const originX = hasPage ? layout.pageX : layout.x;
204
+ const originY = hasPage ? layout.pageY : layout.y;
205
+ if (typeof originX !== "number" || typeof originY !== "number" || typeof layout.width !== "number" || typeof layout.height !== "number" || !Number.isFinite(originX) || !Number.isFinite(originY) || !Number.isFinite(layout.width) || !Number.isFinite(layout.height)) {
206
+ return void 0;
207
+ }
208
+ return {
209
+ x: toPx(originX),
210
+ y: toPx(originY),
211
+ w: toPx(layout.width),
212
+ h: toPx(layout.height)
213
+ };
214
+ }
23
215
  function inferActions(type) {
24
216
  const baseActions = ["focus", "blur"];
25
217
  switch (type) {
@@ -84,6 +276,15 @@ var NativeUIBridgeRegistry = class {
84
276
  * already installs.
85
277
  */
86
278
  this.routeProvider = null;
279
+ /**
280
+ * Device pixel ratio used to project `state.layout` (logical dp) into the
281
+ * physical-pixel `bbox` emitted on each snapshot element. Defaults to `1`
282
+ * (dp ≈ px) so callers/tests that never inject a ratio still get a usable
283
+ * box; the React provider wires the real `PixelRatio.get()` via
284
+ * {@link setPixelRatio}. Read from a non-React core module on purpose — see
285
+ * the `projectBbox` doc comment for why we don't import `react-native` here.
286
+ */
287
+ this.pixelRatio = 1;
87
288
  /**
88
289
  * Sticky flag: flips `true` the first time any element is registered and
89
290
  * stays `true` even after elements are unregistered. Lets agents distinguish
@@ -121,6 +322,21 @@ var NativeUIBridgeRegistry = class {
121
322
  getRouteProvider() {
122
323
  return this.routeProvider;
123
324
  }
325
+ /**
326
+ * Set the device pixel ratio used to project per-element `bbox` geometry
327
+ * into physical pixels. The React provider passes `PixelRatio.get()` here
328
+ * once at startup. Ignores non-finite / non-positive values (keeps the
329
+ * previous ratio) so a bad call can't zero out every bbox.
330
+ */
331
+ setPixelRatio(ratio) {
332
+ if (typeof ratio === "number" && Number.isFinite(ratio) && ratio > 0) {
333
+ this.pixelRatio = ratio;
334
+ }
335
+ }
336
+ /** Read-only accessor for the device pixel ratio used for bbox projection. */
337
+ getPixelRatio() {
338
+ return this.pixelRatio;
339
+ }
124
340
  // ============================================================================
125
341
  // Snapshot Enricher Slots
126
342
  // ============================================================================
@@ -346,6 +562,103 @@ var NativeUIBridgeRegistry = class {
346
562
  console.log(`[ui-bridge-native] Marked ${cleared} elements offscreen for route: ${route}`);
347
563
  }
348
564
  }
565
+ /**
566
+ * Re-measure every mounted-visible element's geometry via its stored React
567
+ * ref and write the fresh coordinates into the registry ("measure-on-
568
+ * snapshot").
569
+ *
570
+ * Why: element geometry is otherwise captured ONCE at mount time
571
+ * (`onLayout` + `measureInWindow` in `useUIElement`) and RN never re-fires
572
+ * `onLayout` on pure translation — scrolling a virtualized list
573
+ * (FlashList), recycling a cell, a keyboard shift or an orientation change
574
+ * all move elements WITHOUT updating the stored `state.layout`. Snapshot
575
+ * consumers (vision analyzers hit-testing `bbox`, coord-based tap) then
576
+ * operate on stale positions. Re-measuring at READ time is correct
577
+ * regardless of what moved the element and needs zero per-consumer wiring:
578
+ * every registry entry already stores its ref (see `registerElement`).
579
+ *
580
+ * Per-element outcomes:
581
+ * - non-zero dims → write a fresh full layout (same state shape as the
582
+ * mount-time measure loop in `useUIElement`); counted as `measured`.
583
+ * - zero dims AND the element previously had a non-null `state.layout` →
584
+ * the view collapsed / left the screen; clear to
585
+ * `{ visible: false, layout: null }` (same shape `markRouteOffscreen`
586
+ * writes); counted as `cleared`.
587
+ * - zero dims and it never had a layout → leave state untouched (element
588
+ * in the mount→first-onLayout gap; don't demote `likely-visible`);
589
+ * counted as `skipped`.
590
+ * - no ref / no callable `measureInWindow` (test fixtures, web) → leave
591
+ * untouched; counted as `skipped`.
592
+ *
593
+ * The whole sweep races an overall timeout (default 250 ms) so a dead ref
594
+ * whose `measureInWindow` never calls back can never hang a snapshot: the
595
+ * returned promise resolves at the deadline with whatever counts have
596
+ * accumulated. Late callbacks may still write state afterwards — harmless,
597
+ * freshest data wins. Never throws.
598
+ *
599
+ * Non-React core module on purpose — `measureInWindow` is duck-typed off
600
+ * the stored ref rather than imported from `react-native` (see the
601
+ * `projectBbox` doc comment for why this file must not import RN).
602
+ */
603
+ async refreshMeasurements(options) {
604
+ const timeoutMs = typeof options?.timeoutMs === "number" && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 250;
605
+ const counts = { measured: 0, cleared: 0, skipped: 0 };
606
+ const pending = [];
607
+ for (const element of this.getMountedVisibleElements()) {
608
+ const node = element.ref?.current;
609
+ if (!node || typeof node.measureInWindow !== "function") {
610
+ counts.skipped++;
611
+ continue;
612
+ }
613
+ const id = element.id;
614
+ pending.push(
615
+ new Promise((resolve) => {
616
+ let settled = false;
617
+ try {
618
+ node.measureInWindow((pageX, pageY, w, h) => {
619
+ if (settled) return;
620
+ settled = true;
621
+ try {
622
+ if (w > 0 && h > 0) {
623
+ this.updateElementState(id, {
624
+ mounted: true,
625
+ visible: true,
626
+ enabled: true,
627
+ focused: false,
628
+ layout: { x: pageX, y: pageY, width: w, height: h, pageX, pageY }
629
+ });
630
+ counts.measured++;
631
+ } else if (this.elements.get(id)?.getState().layout != null) {
632
+ this.updateElementState(id, { visible: false, layout: null });
633
+ counts.cleared++;
634
+ } else {
635
+ counts.skipped++;
636
+ }
637
+ } finally {
638
+ resolve();
639
+ }
640
+ });
641
+ } catch {
642
+ if (!settled) {
643
+ settled = true;
644
+ counts.skipped++;
645
+ resolve();
646
+ }
647
+ }
648
+ })
649
+ );
650
+ }
651
+ if (pending.length > 0) {
652
+ await new Promise((resolve) => {
653
+ const timer = setTimeout(resolve, timeoutMs);
654
+ void Promise.all(pending).then(() => {
655
+ clearTimeout(timer);
656
+ resolve();
657
+ });
658
+ });
659
+ }
660
+ return counts;
661
+ }
349
662
  /**
350
663
  * Update element state
351
664
  */
@@ -660,6 +973,18 @@ var NativeUIBridgeRegistry = class {
660
973
  const handlers = extractHandlerNames(e.props);
661
974
  const state = e.getState();
662
975
  const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
976
+ const bbox = projectBbox(state, visibility, this.pixelRatio);
977
+ const vision = projectVisionFields({
978
+ type: e.type,
979
+ label: e.label,
980
+ // Real interactivity signal: the names of props that are actually
981
+ // functions (onPress/onChangeText/...). NOT `e.actions`, which
982
+ // `inferActions` synthesizes press/click onto for every element —
983
+ // using it would mark plain text/view nodes interactable.
984
+ handlerNames: handlers,
985
+ state,
986
+ flatStyle: e.flatStyle
987
+ });
663
988
  return {
664
989
  id: e.id,
665
990
  type: e.type,
@@ -670,7 +995,9 @@ var NativeUIBridgeRegistry = class {
670
995
  customActions: e.customActions ? Object.keys(e.customActions) : void 0,
671
996
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
672
997
  registrationRoute: e.registrationRoute,
673
- visibility
998
+ visibility,
999
+ ...bbox ? { bbox } : {},
1000
+ ...vision
674
1001
  };
675
1002
  }),
676
1003
  components: this.getAllComponents().map((c) => ({
@@ -1896,7 +2223,7 @@ function createDesignHandlers(registry, viewportProvider) {
1896
2223
  var GRID_SIZE = 20;
1897
2224
  var NAV_TYPES = /* @__PURE__ */ new Set(["button", "heading", "badge", "status-message"]);
1898
2225
  var SKIP_TEXT_TYPES = /* @__PURE__ */ new Set(["button", "link", "tab", "menuitem"]);
1899
- var INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
2226
+ var INTERACTIVE_TYPES2 = /* @__PURE__ */ new Set([
1900
2227
  "button",
1901
2228
  "input",
1902
2229
  "switch",
@@ -2093,7 +2420,7 @@ function diagnosePageHealth(elements, viewport) {
2093
2420
  let interactiveTotal = 0;
2094
2421
  let interactiveDisabled = 0;
2095
2422
  for (const el of elements) {
2096
- if (!INTERACTIVE_TYPES.has(el.type)) continue;
2423
+ if (!INTERACTIVE_TYPES2.has(el.type)) continue;
2097
2424
  interactiveTotal++;
2098
2425
  if (el.state.enabled === false) interactiveDisabled++;
2099
2426
  }
@@ -2366,6 +2693,7 @@ function createServerHandlers(registry, executor, config) {
2366
2693
  return {
2367
2694
  // Elements
2368
2695
  getElements: async (ctx) => {
2696
+ await registry.refreshMeasurements();
2369
2697
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
2370
2698
  const forRoute = ctx.query?.route || ctx.body?.route;
2371
2699
  let allElements = visibleOnly ? registry.getMountedVisibleElements() : registry.getAllElements();
@@ -2376,6 +2704,16 @@ function createServerHandlers(registry, executor, config) {
2376
2704
  const handlers = extractHandlerNames(e.props);
2377
2705
  const state = e.getState();
2378
2706
  const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
2707
+ const bbox = projectBbox(state, visibility, registry.getPixelRatio());
2708
+ const vision = projectVisionFields({
2709
+ type: e.type,
2710
+ label: e.label,
2711
+ // Real handler names (onPress/...), NOT inferred actions which
2712
+ // synthesize press/click onto every element. See projectVisionFields.
2713
+ handlerNames: handlers,
2714
+ state,
2715
+ flatStyle: e.flatStyle
2716
+ });
2379
2717
  return {
2380
2718
  id: e.id,
2381
2719
  type: e.type,
@@ -2386,7 +2724,9 @@ function createServerHandlers(registry, executor, config) {
2386
2724
  customActions: e.customActions ? Object.keys(e.customActions) : void 0,
2387
2725
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
2388
2726
  registrationRoute: e.registrationRoute,
2389
- visibility
2727
+ visibility,
2728
+ ...bbox ? { bbox } : {},
2729
+ ...vision
2390
2730
  };
2391
2731
  });
2392
2732
  return success2({ elements });
@@ -2547,6 +2887,7 @@ function createServerHandlers(registry, executor, config) {
2547
2887
  });
2548
2888
  },
2549
2889
  getSnapshot: async (ctx) => {
2890
+ await registry.refreshMeasurements();
2550
2891
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
2551
2892
  const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
2552
2893
  const snapshot = registry.createSnapshot(void 0, { visibleOnly, currentRouteOnly });
@@ -2862,6 +3203,7 @@ function createServerHandlers(registry, executor, config) {
2862
3203
  // that don't measure (the fallback only matches single-rooted screens but
2863
3204
  // is good enough for the unit suite).
2864
3205
  tapAt: async (ctx) => {
3206
+ await registry.refreshMeasurements();
2865
3207
  const body = ctx.body;
2866
3208
  const x = body?.x;
2867
3209
  const y = body?.y;
@@ -3069,7 +3411,7 @@ function createServerHandlers(registry, executor, config) {
3069
3411
  };
3070
3412
  if (config?.appInfo) {
3071
3413
  response.uiBridge = {
3072
- version: "0.6.10",
3414
+ version: "0.6.12",
3073
3415
  ...config.appInfo,
3074
3416
  capabilities: ["elements", "components", "actions", "design"],
3075
3417
  elementCount: stats.elements,
@@ -3661,6 +4003,14 @@ var NativeUIBridgeServer = class {
3661
4003
  };
3662
4004
  };
3663
4005
  }
4006
+ /**
4007
+ * Whether the console/network capture should be installed. The dedicated
4008
+ * `observability` flag wins when set; otherwise falls back to `testHooks`
4009
+ * so consumers predating the flag keep their exact behavior.
4010
+ */
4011
+ observabilityEnabled() {
4012
+ return this.config.observability ?? this.config.testHooks === true;
4013
+ }
3664
4014
  /**
3665
4015
  * Set the server adapter
3666
4016
  */
@@ -3874,7 +4224,7 @@ var NativeUIBridgeServer = class {
3874
4224
  console.warn("[ui-bridge-native] See documentation for supported adapters.");
3875
4225
  return;
3876
4226
  }
3877
- if (this.config.testHooks === true) {
4227
+ if (this.observabilityEnabled()) {
3878
4228
  this.consoleErrorBuffer.install();
3879
4229
  this.networkRequestBuffer.install();
3880
4230
  }
@@ -5376,6 +5726,7 @@ function UIBridgeNativeProvider({
5376
5726
  verbose: config.verbose,
5377
5727
  onEvent
5378
5728
  });
5729
+ registryRef.current.setPixelRatio(reactNative.PixelRatio.get());
5379
5730
  setGlobalRegistry(registryRef.current);
5380
5731
  }
5381
5732
  const registry = registryRef.current;
@@ -5406,6 +5757,10 @@ function UIBridgeNativeProvider({
5406
5757
  cors: true,
5407
5758
  appInfo: config.appInfo,
5408
5759
  testHooks: features.testHooks === true,
5760
+ // Dedicated console/network-capture flag; the server defaults it to
5761
+ // the `testHooks` value when undefined, so passing it through verbatim
5762
+ // keeps existing consumers unchanged.
5763
+ observability: features.observability,
5409
5764
  // Injected device-viewport getter for POST /control/page-health. The
5410
5765
  // server doesn't import `react-native` itself — the require/import
5411
5766
  // pattern there crashed the host RN app in 0.6.3/0.6.4 (Metro/Hermes
@@ -5473,6 +5828,7 @@ function UIBridgeNativeProvider({
5473
5828
  }, [
5474
5829
  features.server,
5475
5830
  features.testHooks,
5831
+ features.observability,
5476
5832
  config.serverPort,
5477
5833
  config.appInfo,
5478
5834
  registry,
@@ -5511,6 +5867,7 @@ function UIBridgeNativeProvider({
5511
5867
  cors: true,
5512
5868
  appInfo: config.appInfo,
5513
5869
  testHooks: features.testHooks === true,
5870
+ observability: features.observability,
5514
5871
  viewportProvider: () => {
5515
5872
  const win = reactNative.Dimensions.get("window");
5516
5873
  return { width: win.width, height: win.height };
@@ -5714,12 +6071,41 @@ function scheduleDeadButtonWarning(bridge, id, type) {
5714
6071
  }
5715
6072
  function useUIElement(options) {
5716
6073
  const bridge = useUIBridgeNativeOptional();
5717
- const ref = react.useRef(null);
5718
6074
  const [registered, setRegistered] = react.useState(false);
5719
6075
  const [_layout, setLayout] = react.useState(null);
5720
6076
  const propsRef = react.useRef({});
5721
6077
  const registeredRef = react.useRef(false);
5722
6078
  const registeredIdRef = react.useRef(null);
6079
+ const bridgeRef = react.useRef(bridge);
6080
+ bridgeRef.current = bridge;
6081
+ const nodeRef = react.useRef(null);
6082
+ const refInterceptorRef = react.useRef(null);
6083
+ if (refInterceptorRef.current === null) {
6084
+ refInterceptorRef.current = {
6085
+ get current() {
6086
+ return nodeRef.current;
6087
+ },
6088
+ set current(node) {
6089
+ const prev = nodeRef.current;
6090
+ nodeRef.current = node;
6091
+ const liveBridge = bridgeRef.current;
6092
+ const registeredId = registeredIdRef.current;
6093
+ if (!liveBridge || !registeredRef.current || registeredId === null) return;
6094
+ if (node == null && prev != null) {
6095
+ const state = liveBridge.registry.getElement(registeredId)?.getState();
6096
+ if (state && state.visible !== false) {
6097
+ liveBridge.registry.updateElementState(registeredId, { visible: false });
6098
+ }
6099
+ } else if (node != null && prev == null) {
6100
+ const state = liveBridge.registry.getElement(registeredId)?.getState();
6101
+ if (state && state.visible !== true) {
6102
+ liveBridge.registry.updateElementState(registeredId, { visible: true });
6103
+ }
6104
+ }
6105
+ }
6106
+ };
6107
+ }
6108
+ const ref = refInterceptorRef.current;
5723
6109
  const {
5724
6110
  id,
5725
6111
  type = "custom",
@@ -5763,7 +6149,7 @@ function useUIElement(options) {
5763
6149
  }
5764
6150
  setRegistered(true);
5765
6151
  scheduleDeadButtonWarning(bridge, id, type);
5766
- }, [bridge, id, type, label, actions, customActions, treePath, style, stateStylesProp, valueProp]);
6152
+ }, [bridge, id, type, label, actions, customActions, treePath, style, stateStylesProp, valueProp, ref]);
5767
6153
  const unregister = react.useCallback(() => {
5768
6154
  if (!bridge || !registeredRef.current) return;
5769
6155
  bridge.registry.unregisterElement(registeredIdRef.current ?? id);
@@ -5799,7 +6185,8 @@ function useUIElement(options) {
5799
6185
  writeLayout({ x, y, width, height, pageX: x, pageY: y });
5800
6186
  }
5801
6187
  },
5802
- [bridge, id]
6188
+ // `ref` is the once-per-hook interceptor object — identity-stable.
6189
+ [bridge, id, ref]
5803
6190
  );
5804
6191
  const registerRef = react.useRef(register);
5805
6192
  const unregisterRef = react.useRef(unregister);
@@ -5816,11 +6203,18 @@ function useUIElement(options) {
5816
6203
  unregisterRef.current();
5817
6204
  }
5818
6205
  };
5819
- }, [autoRegister, bridge]);
6206
+ }, [autoRegister, bridge, id]);
5820
6207
  react.useEffect(() => {
5821
6208
  if (!bridge || !registered) return;
5822
6209
  const node = ref.current;
5823
- if (!node || typeof node.measureInWindow !== "function") return;
6210
+ if (!node) {
6211
+ const seeded = bridge.registry.getElement(id)?.getState();
6212
+ if (seeded && seeded.visible !== false) {
6213
+ bridge.registry.updateElementState(id, { visible: false });
6214
+ }
6215
+ return;
6216
+ }
6217
+ if (typeof node.measureInWindow !== "function") return;
5824
6218
  let cancelled = false;
5825
6219
  let pendingTimer = null;
5826
6220
  const backoffMs = [50, 200, 500, 1e3, 2500];
@@ -5859,7 +6253,7 @@ function useUIElement(options) {
5859
6253
  cancelled = true;
5860
6254
  if (pendingTimer != null) clearTimeout(pendingTimer);
5861
6255
  };
5862
- }, [bridge, registered, id]);
6256
+ }, [bridge, registered, id, ref]);
5863
6257
  const registeredHandlerKeysRef = react.useRef([]);
5864
6258
  react.useEffect(() => {
5865
6259
  if (!bridge || !registered || !handlersProp) return;
@@ -7273,6 +7667,8 @@ exports.createNativeActionExecutor = createNativeActionExecutor;
7273
7667
  exports.createNativeElementIdentifier = createNativeElementIdentifier;
7274
7668
  exports.createNativeServer = createNativeServer;
7275
7669
  exports.createServerHandlers = createServerHandlers;
7670
+ exports.deriveInteractable = deriveInteractable;
7671
+ exports.deriveText = deriveText;
7276
7672
  exports.extractHandlerNames = extractHandlerNames;
7277
7673
  exports.findAllByPattern = findAllByPattern;
7278
7674
  exports.findElementByIdentifier = findElementByIdentifier;
@@ -7281,8 +7677,12 @@ exports.getNativeElementDesignData = getNativeElementDesignData;
7281
7677
  exports.isAddrInUseError = isAddrInUseError;
7282
7678
  exports.mapNativeStyleToExtended = mapNativeStyleToExtended;
7283
7679
  exports.matchesIdentifier = matchesIdentifier;
7680
+ exports.parseColorToRgb = parseColorToRgb;
7681
+ exports.parseFontSizePx = parseFontSizePx;
7284
7682
  exports.parseTreePath = parseTreePath;
7683
+ exports.projectVisionFields = projectVisionFields;
7285
7684
  exports.resetGlobalRegistry = resetGlobalRegistry;
7685
+ exports.roleForType = roleForType;
7286
7686
  exports.setGlobalRegistry = setGlobalRegistry;
7287
7687
  exports.useToastRecorder = useToastRecorder;
7288
7688
  exports.useUIBridge = useUIBridge;