@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.
@@ -1,5 +1,5 @@
1
1
  import { createContext, useRef, useState, useCallback, useEffect, useMemo, use } from 'react';
2
- import { Dimensions, StyleSheet, Platform } from 'react-native';
2
+ import { PixelRatio, Dimensions, StyleSheet, Platform } from 'react-native';
3
3
  import { jsx, Fragment } from 'react/jsx-runtime';
4
4
 
5
5
  var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
@@ -9,6 +9,179 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
9
9
  throw Error('Dynamic require of "' + x + '" is not supported');
10
10
  });
11
11
 
12
+ // src/core/vision-fields.ts
13
+ var INTERACTIVE_HANDLER_NAMES = /* @__PURE__ */ new Set([
14
+ "onPress",
15
+ "onPressIn",
16
+ "onPressOut",
17
+ "onLongPress",
18
+ "onChangeText",
19
+ "onValueChange",
20
+ "onChange",
21
+ "onSubmitEditing",
22
+ "onClick",
23
+ "onTouchStart",
24
+ "onTouchEnd"
25
+ ]);
26
+ var INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
27
+ "button",
28
+ "input",
29
+ "switch",
30
+ "checkbox",
31
+ "radio",
32
+ "touchable",
33
+ "pressable",
34
+ "listItem"
35
+ ]);
36
+ function roleForType(type) {
37
+ switch (type) {
38
+ case "button":
39
+ case "touchable":
40
+ case "pressable":
41
+ return "button";
42
+ case "input":
43
+ return "textbox";
44
+ case "text":
45
+ return "text";
46
+ case "switch":
47
+ return "switch";
48
+ case "checkbox":
49
+ return "checkbox";
50
+ case "radio":
51
+ return "radio";
52
+ case "image":
53
+ return "img";
54
+ case "list":
55
+ return "list";
56
+ case "listItem":
57
+ return "listitem";
58
+ case "modal":
59
+ return "dialog";
60
+ case "scroll":
61
+ case "view":
62
+ case "custom":
63
+ default:
64
+ return void 0;
65
+ }
66
+ }
67
+ function deriveInteractable(type, handlerNames) {
68
+ if (INTERACTIVE_TYPES.has(type)) return true;
69
+ if (handlerNames) {
70
+ for (const name of handlerNames) {
71
+ if (INTERACTIVE_HANDLER_NAMES.has(name)) return true;
72
+ }
73
+ }
74
+ return false;
75
+ }
76
+ function deriveText(state, label) {
77
+ const candidates = [state.value, state.textContent, label];
78
+ for (const c of candidates) {
79
+ if (typeof c === "string") {
80
+ const trimmed = c.trim();
81
+ if (trimmed.length > 0) return trimmed;
82
+ }
83
+ }
84
+ return void 0;
85
+ }
86
+ var NAMED_COLORS = {
87
+ black: { r: 0, g: 0, b: 0 },
88
+ white: { r: 255, g: 255, b: 255 },
89
+ red: { r: 255, g: 0, b: 0 },
90
+ green: { r: 0, g: 128, b: 0 },
91
+ blue: { r: 0, g: 0, b: 255 },
92
+ gray: { r: 128, g: 128, b: 128 },
93
+ grey: { r: 128, g: 128, b: 128 },
94
+ silver: { r: 192, g: 192, b: 192 },
95
+ transparent: { r: 0, g: 0, b: 0 },
96
+ orange: { r: 255, g: 165, b: 0 },
97
+ yellow: { r: 255, g: 255, b: 0 },
98
+ purple: { r: 128, g: 0, b: 128 },
99
+ navy: { r: 0, g: 0, b: 128 }
100
+ };
101
+ var clamp255 = (n) => Math.max(0, Math.min(255, Math.round(n)));
102
+ function parseColorToRgb(input) {
103
+ if (typeof input !== "string") return void 0;
104
+ const raw = input.trim().toLowerCase();
105
+ if (raw.length === 0) return void 0;
106
+ if (Object.prototype.hasOwnProperty.call(NAMED_COLORS, raw)) {
107
+ return NAMED_COLORS[raw];
108
+ }
109
+ if (raw.startsWith("#")) {
110
+ const hex = raw.slice(1);
111
+ if (hex.length === 3 || hex.length === 4) {
112
+ const r = parseInt(hex[0] + hex[0], 16);
113
+ const g = parseInt(hex[1] + hex[1], 16);
114
+ const b = parseInt(hex[2] + hex[2], 16);
115
+ if ([r, g, b].every((v) => Number.isFinite(v))) return { r, g, b };
116
+ return void 0;
117
+ }
118
+ if (hex.length === 6 || hex.length === 8) {
119
+ const r = parseInt(hex.slice(0, 2), 16);
120
+ const g = parseInt(hex.slice(2, 4), 16);
121
+ const b = parseInt(hex.slice(4, 6), 16);
122
+ if ([r, g, b].every((v) => Number.isFinite(v))) return { r, g, b };
123
+ return void 0;
124
+ }
125
+ return void 0;
126
+ }
127
+ const rgbMatch = raw.match(/^rgba?\(([^)]+)\)$/);
128
+ if (rgbMatch) {
129
+ const parts = rgbMatch[1].split(",").map((p) => p.trim());
130
+ if (parts.length < 3) return void 0;
131
+ const channel = (p) => {
132
+ if (p.endsWith("%")) {
133
+ const pct = parseFloat(p.slice(0, -1));
134
+ if (!Number.isFinite(pct)) return void 0;
135
+ return clamp255(pct / 100 * 255);
136
+ }
137
+ const n = parseFloat(p);
138
+ if (!Number.isFinite(n)) return void 0;
139
+ return clamp255(n);
140
+ };
141
+ const r = channel(parts[0]);
142
+ const g = channel(parts[1]);
143
+ const b = channel(parts[2]);
144
+ if (r === void 0 || g === void 0 || b === void 0) return void 0;
145
+ return { r, g, b };
146
+ }
147
+ return void 0;
148
+ }
149
+ function parseFontSizePx(input) {
150
+ if (typeof input === "number") {
151
+ return Number.isFinite(input) && input > 0 ? input : void 0;
152
+ }
153
+ if (typeof input === "string") {
154
+ const m = input.trim().match(/^(-?\d*\.?\d+)/);
155
+ if (m) {
156
+ const n = parseFloat(m[1]);
157
+ return Number.isFinite(n) && n > 0 ? n : void 0;
158
+ }
159
+ }
160
+ return void 0;
161
+ }
162
+ function projectVisionFields(args) {
163
+ const { type, label, handlerNames, state, flatStyle } = args;
164
+ const fields = {
165
+ interactable: deriveInteractable(type, handlerNames)
166
+ };
167
+ const text = deriveText(state, label);
168
+ if (text !== void 0) fields.text = text;
169
+ const role = roleForType(type);
170
+ if (role !== void 0) fields.role = role;
171
+ if (flatStyle) {
172
+ const fg = parseColorToRgb(flatStyle.color);
173
+ if (fg !== void 0) fields.fg_color = fg;
174
+ const bg = parseColorToRgb(flatStyle.backgroundColor);
175
+ if (bg !== void 0) fields.bg_color = bg;
176
+ const size = parseFontSizePx(flatStyle.fontSize);
177
+ if (size !== void 0) fields.font_size_px = size;
178
+ if (typeof flatStyle.fontFamily === "string" && flatStyle.fontFamily.trim().length > 0) {
179
+ fields.font_family = flatStyle.fontFamily;
180
+ }
181
+ }
182
+ return fields;
183
+ }
184
+
12
185
  // src/core/registry.ts
13
186
  function extractHandlerNames(props) {
14
187
  if (!props) return [];
@@ -18,6 +191,25 @@ function matchesCurrentRoute(registrationRoute, currentRoute) {
18
191
  if (registrationRoute == null || registrationRoute === "") return true;
19
192
  return registrationRoute === currentRoute;
20
193
  }
194
+ function projectBbox(state, visibility, pixelRatio = 1) {
195
+ if (visibility !== "visible") return void 0;
196
+ const layout = state.layout;
197
+ if (!layout) return void 0;
198
+ const ratio = pixelRatio > 0 && Number.isFinite(pixelRatio) ? pixelRatio : 1;
199
+ const toPx = (v) => Math.max(0, Math.round(v * ratio));
200
+ const hasPage = typeof layout.pageX === "number" && typeof layout.pageY === "number" && Number.isFinite(layout.pageX) && Number.isFinite(layout.pageY);
201
+ const originX = hasPage ? layout.pageX : layout.x;
202
+ const originY = hasPage ? layout.pageY : layout.y;
203
+ 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)) {
204
+ return void 0;
205
+ }
206
+ return {
207
+ x: toPx(originX),
208
+ y: toPx(originY),
209
+ w: toPx(layout.width),
210
+ h: toPx(layout.height)
211
+ };
212
+ }
21
213
  function inferActions(type) {
22
214
  const baseActions = ["focus", "blur"];
23
215
  switch (type) {
@@ -82,6 +274,15 @@ var NativeUIBridgeRegistry = class {
82
274
  * already installs.
83
275
  */
84
276
  this.routeProvider = null;
277
+ /**
278
+ * Device pixel ratio used to project `state.layout` (logical dp) into the
279
+ * physical-pixel `bbox` emitted on each snapshot element. Defaults to `1`
280
+ * (dp ≈ px) so callers/tests that never inject a ratio still get a usable
281
+ * box; the React provider wires the real `PixelRatio.get()` via
282
+ * {@link setPixelRatio}. Read from a non-React core module on purpose — see
283
+ * the `projectBbox` doc comment for why we don't import `react-native` here.
284
+ */
285
+ this.pixelRatio = 1;
85
286
  /**
86
287
  * Sticky flag: flips `true` the first time any element is registered and
87
288
  * stays `true` even after elements are unregistered. Lets agents distinguish
@@ -119,6 +320,21 @@ var NativeUIBridgeRegistry = class {
119
320
  getRouteProvider() {
120
321
  return this.routeProvider;
121
322
  }
323
+ /**
324
+ * Set the device pixel ratio used to project per-element `bbox` geometry
325
+ * into physical pixels. The React provider passes `PixelRatio.get()` here
326
+ * once at startup. Ignores non-finite / non-positive values (keeps the
327
+ * previous ratio) so a bad call can't zero out every bbox.
328
+ */
329
+ setPixelRatio(ratio) {
330
+ if (typeof ratio === "number" && Number.isFinite(ratio) && ratio > 0) {
331
+ this.pixelRatio = ratio;
332
+ }
333
+ }
334
+ /** Read-only accessor for the device pixel ratio used for bbox projection. */
335
+ getPixelRatio() {
336
+ return this.pixelRatio;
337
+ }
122
338
  // ============================================================================
123
339
  // Snapshot Enricher Slots
124
340
  // ============================================================================
@@ -344,6 +560,103 @@ var NativeUIBridgeRegistry = class {
344
560
  console.log(`[ui-bridge-native] Marked ${cleared} elements offscreen for route: ${route}`);
345
561
  }
346
562
  }
563
+ /**
564
+ * Re-measure every mounted-visible element's geometry via its stored React
565
+ * ref and write the fresh coordinates into the registry ("measure-on-
566
+ * snapshot").
567
+ *
568
+ * Why: element geometry is otherwise captured ONCE at mount time
569
+ * (`onLayout` + `measureInWindow` in `useUIElement`) and RN never re-fires
570
+ * `onLayout` on pure translation — scrolling a virtualized list
571
+ * (FlashList), recycling a cell, a keyboard shift or an orientation change
572
+ * all move elements WITHOUT updating the stored `state.layout`. Snapshot
573
+ * consumers (vision analyzers hit-testing `bbox`, coord-based tap) then
574
+ * operate on stale positions. Re-measuring at READ time is correct
575
+ * regardless of what moved the element and needs zero per-consumer wiring:
576
+ * every registry entry already stores its ref (see `registerElement`).
577
+ *
578
+ * Per-element outcomes:
579
+ * - non-zero dims → write a fresh full layout (same state shape as the
580
+ * mount-time measure loop in `useUIElement`); counted as `measured`.
581
+ * - zero dims AND the element previously had a non-null `state.layout` →
582
+ * the view collapsed / left the screen; clear to
583
+ * `{ visible: false, layout: null }` (same shape `markRouteOffscreen`
584
+ * writes); counted as `cleared`.
585
+ * - zero dims and it never had a layout → leave state untouched (element
586
+ * in the mount→first-onLayout gap; don't demote `likely-visible`);
587
+ * counted as `skipped`.
588
+ * - no ref / no callable `measureInWindow` (test fixtures, web) → leave
589
+ * untouched; counted as `skipped`.
590
+ *
591
+ * The whole sweep races an overall timeout (default 250 ms) so a dead ref
592
+ * whose `measureInWindow` never calls back can never hang a snapshot: the
593
+ * returned promise resolves at the deadline with whatever counts have
594
+ * accumulated. Late callbacks may still write state afterwards — harmless,
595
+ * freshest data wins. Never throws.
596
+ *
597
+ * Non-React core module on purpose — `measureInWindow` is duck-typed off
598
+ * the stored ref rather than imported from `react-native` (see the
599
+ * `projectBbox` doc comment for why this file must not import RN).
600
+ */
601
+ async refreshMeasurements(options) {
602
+ const timeoutMs = typeof options?.timeoutMs === "number" && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 250;
603
+ const counts = { measured: 0, cleared: 0, skipped: 0 };
604
+ const pending = [];
605
+ for (const element of this.getMountedVisibleElements()) {
606
+ const node = element.ref?.current;
607
+ if (!node || typeof node.measureInWindow !== "function") {
608
+ counts.skipped++;
609
+ continue;
610
+ }
611
+ const id = element.id;
612
+ pending.push(
613
+ new Promise((resolve) => {
614
+ let settled = false;
615
+ try {
616
+ node.measureInWindow((pageX, pageY, w, h) => {
617
+ if (settled) return;
618
+ settled = true;
619
+ try {
620
+ if (w > 0 && h > 0) {
621
+ this.updateElementState(id, {
622
+ mounted: true,
623
+ visible: true,
624
+ enabled: true,
625
+ focused: false,
626
+ layout: { x: pageX, y: pageY, width: w, height: h, pageX, pageY }
627
+ });
628
+ counts.measured++;
629
+ } else if (this.elements.get(id)?.getState().layout != null) {
630
+ this.updateElementState(id, { visible: false, layout: null });
631
+ counts.cleared++;
632
+ } else {
633
+ counts.skipped++;
634
+ }
635
+ } finally {
636
+ resolve();
637
+ }
638
+ });
639
+ } catch {
640
+ if (!settled) {
641
+ settled = true;
642
+ counts.skipped++;
643
+ resolve();
644
+ }
645
+ }
646
+ })
647
+ );
648
+ }
649
+ if (pending.length > 0) {
650
+ await new Promise((resolve) => {
651
+ const timer = setTimeout(resolve, timeoutMs);
652
+ void Promise.all(pending).then(() => {
653
+ clearTimeout(timer);
654
+ resolve();
655
+ });
656
+ });
657
+ }
658
+ return counts;
659
+ }
347
660
  /**
348
661
  * Update element state
349
662
  */
@@ -658,6 +971,18 @@ var NativeUIBridgeRegistry = class {
658
971
  const handlers = extractHandlerNames(e.props);
659
972
  const state = e.getState();
660
973
  const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
974
+ const bbox = projectBbox(state, visibility, this.pixelRatio);
975
+ const vision = projectVisionFields({
976
+ type: e.type,
977
+ label: e.label,
978
+ // Real interactivity signal: the names of props that are actually
979
+ // functions (onPress/onChangeText/...). NOT `e.actions`, which
980
+ // `inferActions` synthesizes press/click onto for every element —
981
+ // using it would mark plain text/view nodes interactable.
982
+ handlerNames: handlers,
983
+ state,
984
+ flatStyle: e.flatStyle
985
+ });
661
986
  return {
662
987
  id: e.id,
663
988
  type: e.type,
@@ -668,7 +993,9 @@ var NativeUIBridgeRegistry = class {
668
993
  customActions: e.customActions ? Object.keys(e.customActions) : void 0,
669
994
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
670
995
  registrationRoute: e.registrationRoute,
671
- visibility
996
+ visibility,
997
+ ...bbox ? { bbox } : {},
998
+ ...vision
672
999
  };
673
1000
  }),
674
1001
  components: this.getAllComponents().map((c) => ({
@@ -1842,7 +2169,7 @@ function createDesignHandlers(registry, viewportProvider) {
1842
2169
  var GRID_SIZE = 20;
1843
2170
  var NAV_TYPES = /* @__PURE__ */ new Set(["button", "heading", "badge", "status-message"]);
1844
2171
  var SKIP_TEXT_TYPES = /* @__PURE__ */ new Set(["button", "link", "tab", "menuitem"]);
1845
- var INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
2172
+ var INTERACTIVE_TYPES2 = /* @__PURE__ */ new Set([
1846
2173
  "button",
1847
2174
  "input",
1848
2175
  "switch",
@@ -2039,7 +2366,7 @@ function diagnosePageHealth(elements, viewport) {
2039
2366
  let interactiveTotal = 0;
2040
2367
  let interactiveDisabled = 0;
2041
2368
  for (const el of elements) {
2042
- if (!INTERACTIVE_TYPES.has(el.type)) continue;
2369
+ if (!INTERACTIVE_TYPES2.has(el.type)) continue;
2043
2370
  interactiveTotal++;
2044
2371
  if (el.state.enabled === false) interactiveDisabled++;
2045
2372
  }
@@ -2312,6 +2639,7 @@ function createServerHandlers(registry, executor, config) {
2312
2639
  return {
2313
2640
  // Elements
2314
2641
  getElements: async (ctx) => {
2642
+ await registry.refreshMeasurements();
2315
2643
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
2316
2644
  const forRoute = ctx.query?.route || ctx.body?.route;
2317
2645
  let allElements = visibleOnly ? registry.getMountedVisibleElements() : registry.getAllElements();
@@ -2322,6 +2650,16 @@ function createServerHandlers(registry, executor, config) {
2322
2650
  const handlers = extractHandlerNames(e.props);
2323
2651
  const state = e.getState();
2324
2652
  const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
2653
+ const bbox = projectBbox(state, visibility, registry.getPixelRatio());
2654
+ const vision = projectVisionFields({
2655
+ type: e.type,
2656
+ label: e.label,
2657
+ // Real handler names (onPress/...), NOT inferred actions which
2658
+ // synthesize press/click onto every element. See projectVisionFields.
2659
+ handlerNames: handlers,
2660
+ state,
2661
+ flatStyle: e.flatStyle
2662
+ });
2325
2663
  return {
2326
2664
  id: e.id,
2327
2665
  type: e.type,
@@ -2332,7 +2670,9 @@ function createServerHandlers(registry, executor, config) {
2332
2670
  customActions: e.customActions ? Object.keys(e.customActions) : void 0,
2333
2671
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
2334
2672
  registrationRoute: e.registrationRoute,
2335
- visibility
2673
+ visibility,
2674
+ ...bbox ? { bbox } : {},
2675
+ ...vision
2336
2676
  };
2337
2677
  });
2338
2678
  return success2({ elements });
@@ -2493,6 +2833,7 @@ function createServerHandlers(registry, executor, config) {
2493
2833
  });
2494
2834
  },
2495
2835
  getSnapshot: async (ctx) => {
2836
+ await registry.refreshMeasurements();
2496
2837
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
2497
2838
  const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
2498
2839
  const snapshot = registry.createSnapshot(void 0, { visibleOnly, currentRouteOnly });
@@ -2808,6 +3149,7 @@ function createServerHandlers(registry, executor, config) {
2808
3149
  // that don't measure (the fallback only matches single-rooted screens but
2809
3150
  // is good enough for the unit suite).
2810
3151
  tapAt: async (ctx) => {
3152
+ await registry.refreshMeasurements();
2811
3153
  const body = ctx.body;
2812
3154
  const x = body?.x;
2813
3155
  const y = body?.y;
@@ -3015,7 +3357,7 @@ function createServerHandlers(registry, executor, config) {
3015
3357
  };
3016
3358
  if (config?.appInfo) {
3017
3359
  response.uiBridge = {
3018
- version: "0.6.10",
3360
+ version: "0.6.12",
3019
3361
  ...config.appInfo,
3020
3362
  capabilities: ["elements", "components", "actions", "design"],
3021
3363
  elementCount: stats.elements,
@@ -3607,6 +3949,14 @@ var NativeUIBridgeServer = class {
3607
3949
  };
3608
3950
  };
3609
3951
  }
3952
+ /**
3953
+ * Whether the console/network capture should be installed. The dedicated
3954
+ * `observability` flag wins when set; otherwise falls back to `testHooks`
3955
+ * so consumers predating the flag keep their exact behavior.
3956
+ */
3957
+ observabilityEnabled() {
3958
+ return this.config.observability ?? this.config.testHooks === true;
3959
+ }
3610
3960
  /**
3611
3961
  * Set the server adapter
3612
3962
  */
@@ -3820,7 +4170,7 @@ var NativeUIBridgeServer = class {
3820
4170
  console.warn("[ui-bridge-native] See documentation for supported adapters.");
3821
4171
  return;
3822
4172
  }
3823
- if (this.config.testHooks === true) {
4173
+ if (this.observabilityEnabled()) {
3824
4174
  this.consoleErrorBuffer.install();
3825
4175
  this.networkRequestBuffer.install();
3826
4176
  }
@@ -5322,6 +5672,7 @@ function UIBridgeNativeProvider({
5322
5672
  verbose: config.verbose,
5323
5673
  onEvent
5324
5674
  });
5675
+ registryRef.current.setPixelRatio(PixelRatio.get());
5325
5676
  setGlobalRegistry(registryRef.current);
5326
5677
  }
5327
5678
  const registry = registryRef.current;
@@ -5352,6 +5703,10 @@ function UIBridgeNativeProvider({
5352
5703
  cors: true,
5353
5704
  appInfo: config.appInfo,
5354
5705
  testHooks: features.testHooks === true,
5706
+ // Dedicated console/network-capture flag; the server defaults it to
5707
+ // the `testHooks` value when undefined, so passing it through verbatim
5708
+ // keeps existing consumers unchanged.
5709
+ observability: features.observability,
5355
5710
  // Injected device-viewport getter for POST /control/page-health. The
5356
5711
  // server doesn't import `react-native` itself — the require/import
5357
5712
  // pattern there crashed the host RN app in 0.6.3/0.6.4 (Metro/Hermes
@@ -5419,6 +5774,7 @@ function UIBridgeNativeProvider({
5419
5774
  }, [
5420
5775
  features.server,
5421
5776
  features.testHooks,
5777
+ features.observability,
5422
5778
  config.serverPort,
5423
5779
  config.appInfo,
5424
5780
  registry,
@@ -5457,6 +5813,7 @@ function UIBridgeNativeProvider({
5457
5813
  cors: true,
5458
5814
  appInfo: config.appInfo,
5459
5815
  testHooks: features.testHooks === true,
5816
+ observability: features.observability,
5460
5817
  viewportProvider: () => {
5461
5818
  const win = Dimensions.get("window");
5462
5819
  return { width: win.width, height: win.height };
@@ -5660,12 +6017,41 @@ function scheduleDeadButtonWarning(bridge, id, type) {
5660
6017
  }
5661
6018
  function useUIElement(options) {
5662
6019
  const bridge = useUIBridgeNativeOptional();
5663
- const ref = useRef(null);
5664
6020
  const [registered, setRegistered] = useState(false);
5665
6021
  const [_layout, setLayout] = useState(null);
5666
6022
  const propsRef = useRef({});
5667
6023
  const registeredRef = useRef(false);
5668
6024
  const registeredIdRef = useRef(null);
6025
+ const bridgeRef = useRef(bridge);
6026
+ bridgeRef.current = bridge;
6027
+ const nodeRef = useRef(null);
6028
+ const refInterceptorRef = useRef(null);
6029
+ if (refInterceptorRef.current === null) {
6030
+ refInterceptorRef.current = {
6031
+ get current() {
6032
+ return nodeRef.current;
6033
+ },
6034
+ set current(node) {
6035
+ const prev = nodeRef.current;
6036
+ nodeRef.current = node;
6037
+ const liveBridge = bridgeRef.current;
6038
+ const registeredId = registeredIdRef.current;
6039
+ if (!liveBridge || !registeredRef.current || registeredId === null) return;
6040
+ if (node == null && prev != null) {
6041
+ const state = liveBridge.registry.getElement(registeredId)?.getState();
6042
+ if (state && state.visible !== false) {
6043
+ liveBridge.registry.updateElementState(registeredId, { visible: false });
6044
+ }
6045
+ } else if (node != null && prev == null) {
6046
+ const state = liveBridge.registry.getElement(registeredId)?.getState();
6047
+ if (state && state.visible !== true) {
6048
+ liveBridge.registry.updateElementState(registeredId, { visible: true });
6049
+ }
6050
+ }
6051
+ }
6052
+ };
6053
+ }
6054
+ const ref = refInterceptorRef.current;
5669
6055
  const {
5670
6056
  id,
5671
6057
  type = "custom",
@@ -5709,7 +6095,7 @@ function useUIElement(options) {
5709
6095
  }
5710
6096
  setRegistered(true);
5711
6097
  scheduleDeadButtonWarning(bridge, id, type);
5712
- }, [bridge, id, type, label, actions, customActions, treePath, style, stateStylesProp, valueProp]);
6098
+ }, [bridge, id, type, label, actions, customActions, treePath, style, stateStylesProp, valueProp, ref]);
5713
6099
  const unregister = useCallback(() => {
5714
6100
  if (!bridge || !registeredRef.current) return;
5715
6101
  bridge.registry.unregisterElement(registeredIdRef.current ?? id);
@@ -5745,7 +6131,8 @@ function useUIElement(options) {
5745
6131
  writeLayout({ x, y, width, height, pageX: x, pageY: y });
5746
6132
  }
5747
6133
  },
5748
- [bridge, id]
6134
+ // `ref` is the once-per-hook interceptor object — identity-stable.
6135
+ [bridge, id, ref]
5749
6136
  );
5750
6137
  const registerRef = useRef(register);
5751
6138
  const unregisterRef = useRef(unregister);
@@ -5762,11 +6149,18 @@ function useUIElement(options) {
5762
6149
  unregisterRef.current();
5763
6150
  }
5764
6151
  };
5765
- }, [autoRegister, bridge]);
6152
+ }, [autoRegister, bridge, id]);
5766
6153
  useEffect(() => {
5767
6154
  if (!bridge || !registered) return;
5768
6155
  const node = ref.current;
5769
- if (!node || typeof node.measureInWindow !== "function") return;
6156
+ if (!node) {
6157
+ const seeded = bridge.registry.getElement(id)?.getState();
6158
+ if (seeded && seeded.visible !== false) {
6159
+ bridge.registry.updateElementState(id, { visible: false });
6160
+ }
6161
+ return;
6162
+ }
6163
+ if (typeof node.measureInWindow !== "function") return;
5770
6164
  let cancelled = false;
5771
6165
  let pendingTimer = null;
5772
6166
  const backoffMs = [50, 200, 500, 1e3, 2500];
@@ -5805,7 +6199,7 @@ function useUIElement(options) {
5805
6199
  cancelled = true;
5806
6200
  if (pendingTimer != null) clearTimeout(pendingTimer);
5807
6201
  };
5808
- }, [bridge, registered, id]);
6202
+ }, [bridge, registered, id, ref]);
5809
6203
  const registeredHandlerKeysRef = useRef([]);
5810
6204
  useEffect(() => {
5811
6205
  if (!bridge || !registered || !handlersProp) return;