@qontinui/ui-bridge-native 0.6.9 → 0.6.11

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 (45) hide show
  1. package/dist/control/action-executor.d.ts.map +1 -1
  2. package/dist/control/index.js +2 -1
  3. package/dist/control/index.js.map +1 -1
  4. package/dist/control/index.mjs +2 -1
  5. package/dist/control/index.mjs.map +1 -1
  6. package/dist/core/index.js +232 -1
  7. package/dist/core/index.js.map +1 -1
  8. package/dist/core/index.mjs +232 -2
  9. package/dist/core/index.mjs.map +1 -1
  10. package/dist/core/registry.d.ts +46 -1
  11. package/dist/core/registry.d.ts.map +1 -1
  12. package/dist/core/types.d.ts +114 -0
  13. package/dist/core/types.d.ts.map +1 -1
  14. package/dist/core/vision-fields.d.ts +142 -0
  15. package/dist/core/vision-fields.d.ts.map +1 -0
  16. package/dist/debug/index.js.map +1 -1
  17. package/dist/debug/index.mjs.map +1 -1
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +371 -20
  21. package/dist/index.js.map +1 -1
  22. package/dist/index.mjs +365 -22
  23. package/dist/index.mjs.map +1 -1
  24. package/dist/react/UIBridgeNativeProvider.d.ts.map +1 -1
  25. package/dist/react/index.js +323 -20
  26. package/dist/react/index.js.map +1 -1
  27. package/dist/react/index.mjs +324 -21
  28. package/dist/react/index.mjs.map +1 -1
  29. package/dist/react/useUIElement.d.ts.map +1 -1
  30. package/dist/server/bind-retry.d.ts +64 -0
  31. package/dist/server/bind-retry.d.ts.map +1 -0
  32. package/dist/server/handlers.d.ts.map +1 -1
  33. package/dist/server/http-server.d.ts +9 -1
  34. package/dist/server/http-server.d.ts.map +1 -1
  35. package/dist/server/index.d.ts +2 -0
  36. package/dist/server/index.d.ts.map +1 -1
  37. package/dist/server/index.js +275 -13
  38. package/dist/server/index.js.map +1 -1
  39. package/dist/server/index.mjs +274 -14
  40. package/dist/server/index.mjs.map +1 -1
  41. package/dist/server/observability.d.ts +4 -3
  42. package/dist/server/observability.d.ts.map +1 -1
  43. package/dist/server/types.d.ts +10 -0
  44. package/dist/server/types.d.ts.map +1 -1
  45. package/package.json +1 -1
@@ -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
  // ============================================================================
@@ -658,6 +874,18 @@ var NativeUIBridgeRegistry = class {
658
874
  const handlers = extractHandlerNames(e.props);
659
875
  const state = e.getState();
660
876
  const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
877
+ const bbox = projectBbox(state, visibility, this.pixelRatio);
878
+ const vision = projectVisionFields({
879
+ type: e.type,
880
+ label: e.label,
881
+ // Real interactivity signal: the names of props that are actually
882
+ // functions (onPress/onChangeText/...). NOT `e.actions`, which
883
+ // `inferActions` synthesizes press/click onto for every element —
884
+ // using it would mark plain text/view nodes interactable.
885
+ handlerNames: handlers,
886
+ state,
887
+ flatStyle: e.flatStyle
888
+ });
661
889
  return {
662
890
  id: e.id,
663
891
  type: e.type,
@@ -668,7 +896,9 @@ var NativeUIBridgeRegistry = class {
668
896
  customActions: e.customActions ? Object.keys(e.customActions) : void 0,
669
897
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
670
898
  registrationRoute: e.registrationRoute,
671
- visibility
899
+ visibility,
900
+ ...bbox ? { bbox } : {},
901
+ ...vision
672
902
  };
673
903
  }),
674
904
  components: this.getAllComponents().map((c) => ({
@@ -933,9 +1163,10 @@ var DefaultNativeActionExecutor = class {
933
1163
  requestId: request.requestId,
934
1164
  durationMs: successTime - startTime
935
1165
  });
1166
+ const fresh = this.registry.getElement(registered.id) ?? registered;
936
1167
  return {
937
1168
  success: true,
938
- elementState: registered.getState(),
1169
+ elementState: fresh.getState(),
939
1170
  result,
940
1171
  durationMs: successTime - startTime,
941
1172
  timestamp: successTime,
@@ -1841,7 +2072,7 @@ function createDesignHandlers(registry, viewportProvider) {
1841
2072
  var GRID_SIZE = 20;
1842
2073
  var NAV_TYPES = /* @__PURE__ */ new Set(["button", "heading", "badge", "status-message"]);
1843
2074
  var SKIP_TEXT_TYPES = /* @__PURE__ */ new Set(["button", "link", "tab", "menuitem"]);
1844
- var INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
2075
+ var INTERACTIVE_TYPES2 = /* @__PURE__ */ new Set([
1845
2076
  "button",
1846
2077
  "input",
1847
2078
  "switch",
@@ -2038,7 +2269,7 @@ function diagnosePageHealth(elements, viewport) {
2038
2269
  let interactiveTotal = 0;
2039
2270
  let interactiveDisabled = 0;
2040
2271
  for (const el of elements) {
2041
- if (!INTERACTIVE_TYPES.has(el.type)) continue;
2272
+ if (!INTERACTIVE_TYPES2.has(el.type)) continue;
2042
2273
  interactiveTotal++;
2043
2274
  if (el.state.enabled === false) interactiveDisabled++;
2044
2275
  }
@@ -2106,6 +2337,13 @@ function matchElementsByText(registry, text, opts) {
2106
2337
  scored.sort((a, b) => a.rank - b.rank);
2107
2338
  return scored.map((s) => s.element);
2108
2339
  }
2340
+ function resolveElementState(element) {
2341
+ const state = element.getState();
2342
+ if (element.type === "input" && state.value === void 0 && typeof element.props?.value === "string") {
2343
+ return { ...state, value: element.props.value };
2344
+ }
2345
+ return state;
2346
+ }
2109
2347
  async function executeWorkflowStep(step, registry, executor) {
2110
2348
  const startTime = Date.now();
2111
2349
  try {
@@ -2314,6 +2552,16 @@ function createServerHandlers(registry, executor, config) {
2314
2552
  const handlers = extractHandlerNames(e.props);
2315
2553
  const state = e.getState();
2316
2554
  const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
2555
+ const bbox = projectBbox(state, visibility, registry.getPixelRatio());
2556
+ const vision = projectVisionFields({
2557
+ type: e.type,
2558
+ label: e.label,
2559
+ // Real handler names (onPress/...), NOT inferred actions which
2560
+ // synthesize press/click onto every element. See projectVisionFields.
2561
+ handlerNames: handlers,
2562
+ state,
2563
+ flatStyle: e.flatStyle
2564
+ });
2317
2565
  return {
2318
2566
  id: e.id,
2319
2567
  type: e.type,
@@ -2324,7 +2572,9 @@ function createServerHandlers(registry, executor, config) {
2324
2572
  customActions: e.customActions ? Object.keys(e.customActions) : void 0,
2325
2573
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
2326
2574
  registrationRoute: e.registrationRoute,
2327
- visibility
2575
+ visibility,
2576
+ ...bbox ? { bbox } : {},
2577
+ ...vision
2328
2578
  };
2329
2579
  });
2330
2580
  return success2({ elements });
@@ -2342,7 +2592,7 @@ function createServerHandlers(registry, executor, config) {
2342
2592
  type: element.type,
2343
2593
  label: element.label,
2344
2594
  identifier: element.getIdentifier(),
2345
- state: element.getState(),
2595
+ state: resolveElementState(element),
2346
2596
  actions: element.actions,
2347
2597
  customActions: element.customActions ? Object.keys(element.customActions) : void 0,
2348
2598
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
@@ -2356,18 +2606,19 @@ function createServerHandlers(registry, executor, config) {
2356
2606
  if (!element) {
2357
2607
  return error2(`Element not found: ${id}`, "ELEMENT_NOT_FOUND");
2358
2608
  }
2359
- return success2({ state: element.getState() });
2609
+ return success2({ state: resolveElementState(element) });
2360
2610
  },
2361
2611
  executeAction: async (ctx) => {
2362
- const { id } = ctx.params;
2363
- const body = ctx.body;
2364
- if (!body?.action) {
2612
+ const rawBody = ctx.body ?? {};
2613
+ const envelope = rawBody.request && typeof rawBody.request === "object" ? rawBody.request : rawBody;
2614
+ const id = ctx.params.id ?? rawBody.id ?? "";
2615
+ if (!envelope?.action) {
2365
2616
  return error2("Action is required", "INVALID_REQUEST");
2366
2617
  }
2367
2618
  const response = await executor.executeAction(id, {
2368
- action: body.action,
2369
- params: body.params,
2370
- waitOptions: body.waitOptions
2619
+ action: envelope.action,
2620
+ params: envelope.params,
2621
+ waitOptions: envelope.waitOptions
2371
2622
  });
2372
2623
  if (!response.success) {
2373
2624
  return error2(response.error || "Action failed", "ACTION_FAILED");
@@ -3006,7 +3257,7 @@ function createServerHandlers(registry, executor, config) {
3006
3257
  };
3007
3258
  if (config?.appInfo) {
3008
3259
  response.uiBridge = {
3009
- version: "0.3.0",
3260
+ version: "0.6.11",
3010
3261
  ...config.appInfo,
3011
3262
  capabilities: ["elements", "components", "actions", "design"],
3012
3263
  elementCount: stats.elements,
@@ -3598,6 +3849,14 @@ var NativeUIBridgeServer = class {
3598
3849
  };
3599
3850
  };
3600
3851
  }
3852
+ /**
3853
+ * Whether the console/network capture should be installed. The dedicated
3854
+ * `observability` flag wins when set; otherwise falls back to `testHooks`
3855
+ * so consumers predating the flag keep their exact behavior.
3856
+ */
3857
+ observabilityEnabled() {
3858
+ return this.config.observability ?? this.config.testHooks === true;
3859
+ }
3601
3860
  /**
3602
3861
  * Set the server adapter
3603
3862
  */
@@ -3811,7 +4070,7 @@ var NativeUIBridgeServer = class {
3811
4070
  console.warn("[ui-bridge-native] See documentation for supported adapters.");
3812
4071
  return;
3813
4072
  }
3814
- if (this.config.testHooks === true) {
4073
+ if (this.observabilityEnabled()) {
3815
4074
  this.consoleErrorBuffer.install();
3816
4075
  this.networkRequestBuffer.install();
3817
4076
  }
@@ -5313,6 +5572,7 @@ function UIBridgeNativeProvider({
5313
5572
  verbose: config.verbose,
5314
5573
  onEvent
5315
5574
  });
5575
+ registryRef.current.setPixelRatio(PixelRatio.get());
5316
5576
  setGlobalRegistry(registryRef.current);
5317
5577
  }
5318
5578
  const registry = registryRef.current;
@@ -5343,6 +5603,10 @@ function UIBridgeNativeProvider({
5343
5603
  cors: true,
5344
5604
  appInfo: config.appInfo,
5345
5605
  testHooks: features.testHooks === true,
5606
+ // Dedicated console/network-capture flag; the server defaults it to
5607
+ // the `testHooks` value when undefined, so passing it through verbatim
5608
+ // keeps existing consumers unchanged.
5609
+ observability: features.observability,
5346
5610
  // Injected device-viewport getter for POST /control/page-health. The
5347
5611
  // server doesn't import `react-native` itself — the require/import
5348
5612
  // pattern there crashed the host RN app in 0.6.3/0.6.4 (Metro/Hermes
@@ -5410,6 +5674,7 @@ function UIBridgeNativeProvider({
5410
5674
  }, [
5411
5675
  features.server,
5412
5676
  features.testHooks,
5677
+ features.observability,
5413
5678
  config.serverPort,
5414
5679
  config.appInfo,
5415
5680
  registry,
@@ -5448,6 +5713,7 @@ function UIBridgeNativeProvider({
5448
5713
  cors: true,
5449
5714
  appInfo: config.appInfo,
5450
5715
  testHooks: features.testHooks === true,
5716
+ observability: features.observability,
5451
5717
  viewportProvider: () => {
5452
5718
  const win = Dimensions.get("window");
5453
5719
  return { width: win.width, height: win.height };
@@ -5651,12 +5917,41 @@ function scheduleDeadButtonWarning(bridge, id, type) {
5651
5917
  }
5652
5918
  function useUIElement(options) {
5653
5919
  const bridge = useUIBridgeNativeOptional();
5654
- const ref = useRef(null);
5655
5920
  const [registered, setRegistered] = useState(false);
5656
5921
  const [_layout, setLayout] = useState(null);
5657
5922
  const propsRef = useRef({});
5658
5923
  const registeredRef = useRef(false);
5659
5924
  const registeredIdRef = useRef(null);
5925
+ const bridgeRef = useRef(bridge);
5926
+ bridgeRef.current = bridge;
5927
+ const nodeRef = useRef(null);
5928
+ const refInterceptorRef = useRef(null);
5929
+ if (refInterceptorRef.current === null) {
5930
+ refInterceptorRef.current = {
5931
+ get current() {
5932
+ return nodeRef.current;
5933
+ },
5934
+ set current(node) {
5935
+ const prev = nodeRef.current;
5936
+ nodeRef.current = node;
5937
+ const liveBridge = bridgeRef.current;
5938
+ const registeredId = registeredIdRef.current;
5939
+ if (!liveBridge || !registeredRef.current || registeredId === null) return;
5940
+ if (node == null && prev != null) {
5941
+ const state = liveBridge.registry.getElement(registeredId)?.getState();
5942
+ if (state && state.visible !== false) {
5943
+ liveBridge.registry.updateElementState(registeredId, { visible: false });
5944
+ }
5945
+ } else if (node != null && prev == null) {
5946
+ const state = liveBridge.registry.getElement(registeredId)?.getState();
5947
+ if (state && state.visible !== true) {
5948
+ liveBridge.registry.updateElementState(registeredId, { visible: true });
5949
+ }
5950
+ }
5951
+ }
5952
+ };
5953
+ }
5954
+ const ref = refInterceptorRef.current;
5660
5955
  const {
5661
5956
  id,
5662
5957
  type = "custom",
@@ -5700,7 +5995,7 @@ function useUIElement(options) {
5700
5995
  }
5701
5996
  setRegistered(true);
5702
5997
  scheduleDeadButtonWarning(bridge, id, type);
5703
- }, [bridge, id, type, label, actions, customActions, treePath, style, stateStylesProp, valueProp]);
5998
+ }, [bridge, id, type, label, actions, customActions, treePath, style, stateStylesProp, valueProp, ref]);
5704
5999
  const unregister = useCallback(() => {
5705
6000
  if (!bridge || !registeredRef.current) return;
5706
6001
  bridge.registry.unregisterElement(registeredIdRef.current ?? id);
@@ -5736,7 +6031,8 @@ function useUIElement(options) {
5736
6031
  writeLayout({ x, y, width, height, pageX: x, pageY: y });
5737
6032
  }
5738
6033
  },
5739
- [bridge, id]
6034
+ // `ref` is the once-per-hook interceptor object — identity-stable.
6035
+ [bridge, id, ref]
5740
6036
  );
5741
6037
  const registerRef = useRef(register);
5742
6038
  const unregisterRef = useRef(unregister);
@@ -5757,7 +6053,14 @@ function useUIElement(options) {
5757
6053
  useEffect(() => {
5758
6054
  if (!bridge || !registered) return;
5759
6055
  const node = ref.current;
5760
- if (!node || typeof node.measureInWindow !== "function") return;
6056
+ if (!node) {
6057
+ const seeded = bridge.registry.getElement(id)?.getState();
6058
+ if (seeded && seeded.visible !== false) {
6059
+ bridge.registry.updateElementState(id, { visible: false });
6060
+ }
6061
+ return;
6062
+ }
6063
+ if (typeof node.measureInWindow !== "function") return;
5761
6064
  let cancelled = false;
5762
6065
  let pendingTimer = null;
5763
6066
  const backoffMs = [50, 200, 500, 1e3, 2500];
@@ -5796,7 +6099,7 @@ function useUIElement(options) {
5796
6099
  cancelled = true;
5797
6100
  if (pendingTimer != null) clearTimeout(pendingTimer);
5798
6101
  };
5799
- }, [bridge, registered, id]);
6102
+ }, [bridge, registered, id, ref]);
5800
6103
  const registeredHandlerKeysRef = useRef([]);
5801
6104
  useEffect(() => {
5802
6105
  if (!bridge || !registered || !handlersProp) return;