@qontinui/ui-bridge-native 0.6.11 → 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 +853 -15
  19. package/dist/core/index.js.map +1 -1
  20. package/dist/core/index.mjs +835 -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 +317 -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 +1603 -118
  33. package/dist/index.js.map +1 -1
  34. package/dist/index.mjs +1585 -119
  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 +1420 -113
  39. package/dist/react/index.js.map +1 -1
  40. package/dist/react/index.mjs +1420 -113
  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 +348 -43
  52. package/dist/server/index.js.map +1 -1
  53. package/dist/server/index.mjs +348 -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
@@ -1,3 +1,465 @@
1
+ // src/core/abortable.ts
2
+ var MAX_ACTION_TIMEOUT_MS = 864e5;
3
+ function normalizeActionTimeoutMs(raw) {
4
+ if (raw === void 0 || raw === null) {
5
+ return { ok: true, timeoutMs: void 0, clamped: false };
6
+ }
7
+ if (typeof raw !== "number") {
8
+ return {
9
+ ok: false,
10
+ reason: `timeoutMs must be a number of milliseconds, received ${typeof raw === "string" ? JSON.stringify(raw) : typeof raw}`
11
+ };
12
+ }
13
+ if (!Number.isFinite(raw)) {
14
+ return {
15
+ ok: false,
16
+ reason: `timeoutMs must be a finite number of milliseconds, received ${String(raw)}`
17
+ };
18
+ }
19
+ if (raw < 0) {
20
+ return {
21
+ ok: false,
22
+ reason: `timeoutMs must not be negative, received ${String(raw)}`
23
+ };
24
+ }
25
+ if (raw > MAX_ACTION_TIMEOUT_MS) {
26
+ return { ok: true, timeoutMs: MAX_ACTION_TIMEOUT_MS, clamped: true };
27
+ }
28
+ return { ok: true, timeoutMs: Math.floor(raw), clamped: false };
29
+ }
30
+ function inertAbortSignal() {
31
+ return new AbortController().signal;
32
+ }
33
+ async function runAbortable(invoke, options = {}) {
34
+ const callerSignal = options.signal;
35
+ const timeoutMs = options.timeoutMs;
36
+ if (callerSignal?.aborted) {
37
+ return { aborted: true, reason: "signal" };
38
+ }
39
+ const controller = new AbortController();
40
+ let reason = "signal";
41
+ let timer;
42
+ const onCallerAbort = () => {
43
+ reason = "signal";
44
+ controller.abort();
45
+ };
46
+ callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
47
+ if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs >= 0) {
48
+ timer = setTimeout(
49
+ () => {
50
+ reason = "timeout";
51
+ controller.abort();
52
+ },
53
+ Math.min(timeoutMs, MAX_ACTION_TIMEOUT_MS)
54
+ );
55
+ }
56
+ try {
57
+ const abandoned = new Promise((resolve) => {
58
+ controller.signal.addEventListener("abort", () => resolve({ aborted: true, reason }), {
59
+ once: true
60
+ });
61
+ });
62
+ const completed = (async () => ({
63
+ aborted: false,
64
+ result: await invoke(controller.signal)
65
+ }))();
66
+ return await Promise.race([completed, abandoned]);
67
+ } finally {
68
+ if (timer !== void 0) clearTimeout(timer);
69
+ callerSignal?.removeEventListener("abort", onCallerAbort);
70
+ }
71
+ }
72
+
73
+ // src/core/param-schema.ts
74
+ var DEFAULT_PARAM_VALIDATION_MODE = "warn";
75
+ var currentDefaultMode = DEFAULT_PARAM_VALIDATION_MODE;
76
+ function setDefaultParamValidationMode(mode) {
77
+ currentDefaultMode = mode;
78
+ }
79
+ function getDefaultParamValidationMode() {
80
+ return currentDefaultMode;
81
+ }
82
+ function resetDefaultParamValidationMode() {
83
+ currentDefaultMode = DEFAULT_PARAM_VALIDATION_MODE;
84
+ }
85
+ var PRIMITIVE_TYPE_NAMES = /* @__PURE__ */ new Set([
86
+ "string",
87
+ "number",
88
+ "integer",
89
+ "boolean",
90
+ "object",
91
+ "array",
92
+ "null"
93
+ ]);
94
+ var OBJECT_SCHEMA_KEYWORDS = /* @__PURE__ */ new Set([
95
+ "type",
96
+ "properties",
97
+ "required",
98
+ "additionalProperties",
99
+ "patternProperties",
100
+ "propertyNames",
101
+ "minProperties",
102
+ "maxProperties",
103
+ "dependentRequired",
104
+ "dependentSchemas",
105
+ "unevaluatedProperties",
106
+ "allOf",
107
+ "anyOf",
108
+ "oneOf",
109
+ "not",
110
+ "if",
111
+ "then",
112
+ "else",
113
+ "enum",
114
+ "const",
115
+ "default",
116
+ "examples",
117
+ "title",
118
+ "description",
119
+ "deprecated",
120
+ "readOnly",
121
+ "writeOnly",
122
+ "$schema",
123
+ "$id",
124
+ "$ref",
125
+ "$comment",
126
+ "$defs",
127
+ "definitions"
128
+ ]);
129
+ var MAX_SCHEMA_DEPTH = 32;
130
+ var MAX_PATTERN_LENGTH = 200;
131
+ function hasNestedQuantifiers(pattern) {
132
+ return /(\((?:[^()]*[+*}])[^()]*\))[+*?]|\(\?:[^()]*[+*}][^()]*\)[+*?]/.test(pattern);
133
+ }
134
+ function isPlainObject(value) {
135
+ return typeof value === "object" && value !== null && !Array.isArray(value);
136
+ }
137
+ var hasOwnProperty = Object.prototype.hasOwnProperty;
138
+ function hasOwn(value, key) {
139
+ return hasOwnProperty.call(value, key);
140
+ }
141
+ function render(value) {
142
+ if (value === null) return "null";
143
+ if (value === void 0) return "undefined";
144
+ if (Array.isArray(value)) return `array(${value.length})`;
145
+ const t = typeof value;
146
+ if (t === "string") return JSON.stringify(value);
147
+ if (t === "number" || t === "boolean") return String(value);
148
+ if (t === "object") return "object";
149
+ return t;
150
+ }
151
+ var MAX_RENDERED_JSON_LENGTH = 120;
152
+ function renderJson(value) {
153
+ if (value === void 0) return "undefined";
154
+ let text;
155
+ try {
156
+ text = JSON.stringify(value) ?? String(value);
157
+ } catch {
158
+ return render(value);
159
+ }
160
+ return text.length > MAX_RENDERED_JSON_LENGTH ? `${text.slice(0, MAX_RENDERED_JSON_LENGTH)}\u2026` : text;
161
+ }
162
+ function jsonTypeOf(value) {
163
+ if (value === null) return "null";
164
+ if (Array.isArray(value)) return "array";
165
+ const t = typeof value;
166
+ if (t === "number") return Number.isInteger(value) ? "integer" : "number";
167
+ if (t === "string" || t === "boolean" || t === "object") return t;
168
+ return t;
169
+ }
170
+ function matchesType(value, typeName) {
171
+ switch (typeName) {
172
+ case "string":
173
+ return typeof value === "string";
174
+ case "number":
175
+ return typeof value === "number" && Number.isFinite(value);
176
+ case "integer":
177
+ return typeof value === "number" && Number.isInteger(value);
178
+ case "boolean":
179
+ return typeof value === "boolean";
180
+ case "null":
181
+ return value === null;
182
+ case "array":
183
+ return Array.isArray(value);
184
+ case "object":
185
+ return isPlainObject(value);
186
+ default:
187
+ return true;
188
+ }
189
+ }
190
+ function jsonEquals(a, b, depth = 0) {
191
+ if (a === b) return true;
192
+ if (depth > MAX_SCHEMA_DEPTH) return false;
193
+ if (a === null || b === null || a === void 0 || b === void 0) return false;
194
+ if (typeof a !== typeof b) return false;
195
+ if (typeof a !== "object") return false;
196
+ const aIsArray = Array.isArray(a);
197
+ if (aIsArray !== Array.isArray(b)) return false;
198
+ if (aIsArray) {
199
+ const bArr = b;
200
+ const aArr = a;
201
+ if (aArr.length !== bArr.length) return false;
202
+ return aArr.every((item, i) => jsonEquals(item, bArr[i], depth + 1));
203
+ }
204
+ const aObj = a;
205
+ const bObj = b;
206
+ const aKeys = Object.keys(aObj);
207
+ const bKeys = Object.keys(bObj);
208
+ if (aKeys.length !== bKeys.length) return false;
209
+ return aKeys.every((key) => hasOwn(bObj, key) && jsonEquals(aObj[key], bObj[key], depth + 1));
210
+ }
211
+ function joinPath(base, key) {
212
+ return base === "" ? key : `${base}.${key}`;
213
+ }
214
+ function readTypes(schema) {
215
+ const raw = schema["type"];
216
+ if (typeof raw === "string") return [raw];
217
+ if (Array.isArray(raw)) {
218
+ const names = raw.filter((t) => typeof t === "string");
219
+ return names.length > 0 ? names : null;
220
+ }
221
+ return null;
222
+ }
223
+ function validateValue(value, schema, path, issues, depth) {
224
+ if (depth > MAX_SCHEMA_DEPTH) return;
225
+ if (typeof schema === "string") {
226
+ if (PRIMITIVE_TYPE_NAMES.has(schema) && !matchesType(value, schema)) {
227
+ issues.push({
228
+ path,
229
+ keyword: "type",
230
+ expected: schema,
231
+ received: render(value),
232
+ message: `Parameter "${path}" must be of type ${schema}, received ${jsonTypeOf(value)} (${render(value)}).`
233
+ });
234
+ }
235
+ return;
236
+ }
237
+ if (!isPlainObject(schema)) return;
238
+ const types = readTypes(schema);
239
+ if (types !== null && !types.some((t) => matchesType(value, t))) {
240
+ const expected = types.join(" | ");
241
+ issues.push({
242
+ path,
243
+ keyword: "type",
244
+ expected,
245
+ received: render(value),
246
+ message: `Parameter "${path}" must be of type ${expected}, received ${jsonTypeOf(value)} (${render(value)}).`
247
+ });
248
+ return;
249
+ }
250
+ if (hasOwn(schema, "const") && !jsonEquals(value, schema["const"])) {
251
+ const expected = renderJson(schema["const"]);
252
+ const received = renderJson(value);
253
+ issues.push({
254
+ path,
255
+ keyword: "const",
256
+ expected,
257
+ received,
258
+ message: `Parameter "${path}" must equal ${expected}, received ${received}.`
259
+ });
260
+ return;
261
+ }
262
+ const enumValues = schema["enum"];
263
+ if (Array.isArray(enumValues) && !enumValues.some((allowed) => jsonEquals(value, allowed))) {
264
+ const expected = enumValues.map((v) => renderJson(v)).join(", ");
265
+ const received = renderJson(value);
266
+ issues.push({
267
+ path,
268
+ keyword: "enum",
269
+ expected,
270
+ received,
271
+ message: `Parameter "${path}" must be one of [${expected}], received ${received}.`
272
+ });
273
+ return;
274
+ }
275
+ if (typeof value === "number") {
276
+ const min = schema["minimum"];
277
+ if (typeof min === "number" && value < min) {
278
+ issues.push({
279
+ path,
280
+ keyword: "minimum",
281
+ expected: `>= ${min}`,
282
+ received: render(value),
283
+ message: `Parameter "${path}" must be >= ${min}, received ${value}.`
284
+ });
285
+ }
286
+ const max = schema["maximum"];
287
+ if (typeof max === "number" && value > max) {
288
+ issues.push({
289
+ path,
290
+ keyword: "maximum",
291
+ expected: `<= ${max}`,
292
+ received: render(value),
293
+ message: `Parameter "${path}" must be <= ${max}, received ${value}.`
294
+ });
295
+ }
296
+ }
297
+ if (typeof value === "string") {
298
+ const minLength = schema["minLength"];
299
+ if (typeof minLength === "number" && value.length < minLength) {
300
+ issues.push({
301
+ path,
302
+ keyword: "minLength",
303
+ expected: `length >= ${minLength}`,
304
+ received: `length ${value.length}`,
305
+ message: `Parameter "${path}" must be at least ${minLength} characters, received ${value.length}.`
306
+ });
307
+ }
308
+ const maxLength = schema["maxLength"];
309
+ if (typeof maxLength === "number" && value.length > maxLength) {
310
+ issues.push({
311
+ path,
312
+ keyword: "maxLength",
313
+ expected: `length <= ${maxLength}`,
314
+ received: `length ${value.length}`,
315
+ message: `Parameter "${path}" must be at most ${maxLength} characters, received ${value.length}.`
316
+ });
317
+ }
318
+ const pattern = schema["pattern"];
319
+ if (typeof pattern === "string" && pattern.length <= MAX_PATTERN_LENGTH && !hasNestedQuantifiers(pattern)) {
320
+ let re;
321
+ try {
322
+ re = new RegExp(pattern);
323
+ } catch {
324
+ re = null;
325
+ }
326
+ if (re !== null && !re.test(value)) {
327
+ issues.push({
328
+ path,
329
+ keyword: "pattern",
330
+ expected: `match /${pattern}/`,
331
+ received: render(value),
332
+ message: `Parameter "${path}" must match /${pattern}/, received ${render(value)}.`
333
+ });
334
+ }
335
+ }
336
+ }
337
+ if (Array.isArray(value) && hasOwn(schema, "items")) {
338
+ const items = schema["items"];
339
+ value.forEach((element, index) => {
340
+ validateValue(element, items, `${path}[${index}]`, issues, depth + 1);
341
+ });
342
+ }
343
+ if (isPlainObject(value) && (isPlainObject(schema["properties"]) || hasOwn(schema, "required") || schema["additionalProperties"] === false)) {
344
+ validateObject(value, schema, path, issues, depth);
345
+ }
346
+ }
347
+ function validateObject(value, schema, basePath, issues, depth) {
348
+ if (depth > MAX_SCHEMA_DEPTH) return;
349
+ const properties = isPlainObject(schema["properties"]) ? schema["properties"] : {};
350
+ const requiredRaw = schema["required"];
351
+ const required = Array.isArray(requiredRaw) ? requiredRaw.filter((k) => typeof k === "string") : [];
352
+ for (const key of required) {
353
+ if (!hasOwn(value, key) || value[key] === void 0) {
354
+ const at = joinPath(basePath, key);
355
+ issues.push({
356
+ path: at,
357
+ keyword: "required",
358
+ expected: "present",
359
+ message: `Required parameter "${at}" is missing.`
360
+ });
361
+ }
362
+ }
363
+ if (schema["additionalProperties"] === false) {
364
+ const declared = new Set(Object.keys(properties));
365
+ const expected = declared.size === 0 ? "no parameters" : `one of [${[...declared].join(", ")}]`;
366
+ for (const key of Object.keys(value)) {
367
+ if (!declared.has(key)) {
368
+ const at = joinPath(basePath, key);
369
+ issues.push({
370
+ path: at,
371
+ keyword: "additionalProperties",
372
+ expected,
373
+ received: render(value[key]),
374
+ message: `Parameter "${at}" is not declared by the schema, which sets additionalProperties: false.`
375
+ });
376
+ }
377
+ }
378
+ }
379
+ for (const [key, propSchema] of Object.entries(properties)) {
380
+ if (!hasOwn(value, key) || value[key] === void 0) continue;
381
+ validateValue(value[key], propSchema, joinPath(basePath, key), issues, depth + 1);
382
+ }
383
+ }
384
+ function isObjectSchemaForm(schema) {
385
+ if (isPlainObject(schema["properties"])) return true;
386
+ if (schema["type"] !== "object") return false;
387
+ return Object.keys(schema).every((key) => OBJECT_SCHEMA_KEYWORDS.has(key));
388
+ }
389
+ function normalizeMapForm(schema) {
390
+ if (isObjectSchemaForm(schema)) return null;
391
+ const properties = {};
392
+ for (const [key, raw] of Object.entries(schema)) {
393
+ if (raw === null || raw === void 0) continue;
394
+ if (typeof raw === "string") {
395
+ properties[key] = PRIMITIVE_TYPE_NAMES.has(raw) ? { type: raw } : {};
396
+ continue;
397
+ }
398
+ if (isPlainObject(raw)) {
399
+ const { optional: _optional, ...rest } = raw;
400
+ properties[key] = rest;
401
+ continue;
402
+ }
403
+ }
404
+ return { type: "object", properties };
405
+ }
406
+ function validateActionParams(schema, params) {
407
+ if (!isPlainObject(schema)) return { valid: true, issues: [] };
408
+ const normalized = normalizeMapForm(schema) ?? schema;
409
+ const hasProperties = isPlainObject(normalized["properties"]);
410
+ const hasRequired = Array.isArray(normalized["required"]);
411
+ const forbidsExtras = normalized["additionalProperties"] === false;
412
+ if (!hasProperties && !hasRequired && !forbidsExtras) {
413
+ return { valid: true, issues: [] };
414
+ }
415
+ const issues = [];
416
+ if (params === void 0 || params === null) {
417
+ validateObject({}, normalized, "", issues, 0);
418
+ return { valid: issues.length === 0, issues };
419
+ }
420
+ if (!isPlainObject(params)) {
421
+ issues.push({
422
+ path: "",
423
+ keyword: "type",
424
+ expected: "object",
425
+ received: render(params),
426
+ message: `Action params must be an object, received ${jsonTypeOf(params)} (${render(params)}).`
427
+ });
428
+ return { valid: false, issues };
429
+ }
430
+ validateObject(params, normalized, "", issues, 0);
431
+ return { valid: issues.length === 0, issues };
432
+ }
433
+ function formatParamValidationFailure(componentId, actionId, issues) {
434
+ const detail = issues.map((i) => i.message).join(" ");
435
+ return `Action "${actionId}" on component "${componentId}" was rejected: params do not match the action's declared paramSchema. ${detail}`;
436
+ }
437
+
438
+ // src/core/action-effect.ts
439
+ var NATIVE_STANDARD_ACTION_EFFECTS = {
440
+ // --- Reads ---------------------------------------------------------------
441
+ focus: "read",
442
+ blur: "read",
443
+ scroll: "read",
444
+ scrollIntoView: "read",
445
+ longPress: "read",
446
+ // --- Writes --------------------------------------------------------------
447
+ press: "write",
448
+ click: "write",
449
+ doubleTap: "write",
450
+ type: "write",
451
+ setValue: "write",
452
+ clear: "write",
453
+ swipe: "write",
454
+ toggle: "write"
455
+ };
456
+ function nativeStandardActionEffect(actionId) {
457
+ return Object.prototype.hasOwnProperty.call(NATIVE_STANDARD_ACTION_EFFECTS, actionId) ? NATIVE_STANDARD_ACTION_EFFECTS[actionId] : void 0;
458
+ }
459
+ function resolveActionEffect(action) {
460
+ return action.effect ?? nativeStandardActionEffect(action.id);
461
+ }
462
+
1
463
  // src/core/vision-fields.ts
2
464
  var INTERACTIVE_HANDLER_NAMES = /* @__PURE__ */ new Set([
3
465
  "onPress",
@@ -171,7 +633,42 @@ function projectVisionFields(args) {
171
633
  return fields;
172
634
  }
173
635
 
636
+ // src/core/element-actions.ts
637
+ function serializeElementCustomActions(customActions) {
638
+ if (!customActions) return void 0;
639
+ const out = [];
640
+ for (const [id, action] of Object.entries(customActions)) {
641
+ if (!action) continue;
642
+ const entry = { id };
643
+ if (action.label !== void 0) entry.label = action.label;
644
+ if (action.description !== void 0) entry.description = action.description;
645
+ if (action.effect !== void 0) entry.effect = action.effect;
646
+ out.push(entry);
647
+ }
648
+ return out;
649
+ }
650
+
174
651
  // src/core/registry.ts
652
+ function toRegisteredComponentActions(actions) {
653
+ return actions.map((a) => ({
654
+ id: a.id,
655
+ label: a.label,
656
+ description: a.description,
657
+ paramSchema: a.paramSchema,
658
+ effect: a.effect,
659
+ handler: a.handler
660
+ }));
661
+ }
662
+ function publishedActionSignature(actions) {
663
+ return JSON.stringify(
664
+ actions.map((a) => [a.id, a.label, a.description, a.paramSchema, a.effect])
665
+ );
666
+ }
667
+ function sameElementIds(a, b) {
668
+ if (a === b) return true;
669
+ if (a === void 0 || b === void 0) return false;
670
+ return a.length === b.length && a.every((id, i) => id === b[i]);
671
+ }
175
672
  function extractHandlerNames(props) {
176
673
  if (!props) return [];
177
674
  return Object.keys(props).filter((k) => typeof props[k] === "function");
@@ -180,6 +677,22 @@ function matchesCurrentRoute(registrationRoute, currentRoute) {
180
677
  if (registrationRoute == null || registrationRoute === "") return true;
181
678
  return registrationRoute === currentRoute;
182
679
  }
680
+ function deriveActiveTabFromSegments(segments) {
681
+ if (!segments || segments.length === 0) return void 0;
682
+ let innermostGroup = -1;
683
+ for (let i = 0; i < segments.length; i++) {
684
+ const segment = segments[i];
685
+ if (typeof segment === "string" && segment.startsWith("(") && segment.endsWith(")")) {
686
+ innermostGroup = i;
687
+ }
688
+ }
689
+ if (innermostGroup === -1) return void 0;
690
+ const next = segments[innermostGroup + 1];
691
+ if (next === void 0) return "index";
692
+ if (typeof next !== "string" || next.length === 0) return void 0;
693
+ if (next.startsWith("[") && next.endsWith("]")) return void 0;
694
+ return next;
695
+ }
183
696
  function projectBbox(state, visibility, pixelRatio = 1) {
184
697
  if (visibility !== "visible") return void 0;
185
698
  const layout = state.layout;
@@ -199,8 +712,58 @@ function projectBbox(state, visibility, pixelRatio = 1) {
199
712
  h: toPx(layout.height)
200
713
  };
201
714
  }
202
- function inferActions(type) {
203
- const baseActions = ["focus", "blur"];
715
+ function pageRectOf(state) {
716
+ const layout = state.layout;
717
+ if (!layout) return null;
718
+ if (layout.pageOriginUnmeasured === true) return null;
719
+ const hasPage = typeof layout.pageX === "number" && typeof layout.pageY === "number" && Number.isFinite(layout.pageX) && Number.isFinite(layout.pageY);
720
+ if (!hasPage) return null;
721
+ const left = layout.pageX;
722
+ const top = layout.pageY;
723
+ if (typeof layout.width !== "number" || typeof layout.height !== "number" || !Number.isFinite(layout.width) || !Number.isFinite(layout.height)) {
724
+ return null;
725
+ }
726
+ return { left, top, right: left + layout.width, bottom: top + layout.height };
727
+ }
728
+ function spansOverlap(aMin, aMax, bMin, bMax) {
729
+ if (aMin === aMax) return aMin >= bMin && aMin <= bMax;
730
+ return aMin < bMax && aMax > bMin;
731
+ }
732
+ function isEmptyRect(rect) {
733
+ return rect.right <= rect.left || rect.bottom <= rect.top;
734
+ }
735
+ function intersectRects(a, b) {
736
+ if (!a) return b;
737
+ if (!b) return a;
738
+ return {
739
+ left: Math.max(a.left, b.left),
740
+ top: Math.max(a.top, b.top),
741
+ right: Math.min(a.right, b.right),
742
+ bottom: Math.min(a.bottom, b.bottom)
743
+ };
744
+ }
745
+ function computeVisibility(state, clip) {
746
+ if (!state.visible) {
747
+ return { visibility: "hidden", visibilityReason: "hidden" };
748
+ }
749
+ if (state.layout === null) {
750
+ return { visibility: "likely-visible", visibilityReason: "no-layout" };
751
+ }
752
+ if (!clip) {
753
+ return { visibility: "visible" };
754
+ }
755
+ const rect = pageRectOf(state);
756
+ if (!rect) {
757
+ return { visibility: "visible" };
758
+ }
759
+ if (isEmptyRect(clip)) {
760
+ return { visibility: "hidden", visibilityReason: "off-screen" };
761
+ }
762
+ const onScreen = spansOverlap(rect.left, rect.right, clip.left, clip.right) && spansOverlap(rect.top, rect.bottom, clip.top, clip.bottom);
763
+ return onScreen ? { visibility: "visible" } : { visibility: "hidden", visibilityReason: "off-screen" };
764
+ }
765
+ function inferActions(type, scrollAncestorId) {
766
+ const baseActions = scrollAncestorId ? ["focus", "blur", "scrollIntoView"] : ["focus", "blur"];
204
767
  switch (type) {
205
768
  case "button":
206
769
  case "touchable":
@@ -272,6 +835,18 @@ var NativeUIBridgeRegistry = class {
272
835
  * the `projectBbox` doc comment for why we don't import `react-native` here.
273
836
  */
274
837
  this.pixelRatio = 1;
838
+ /**
839
+ * Injected getter for the device window size in logical dp, used to clip
840
+ * reported visibility. Injected rather than read here for exactly the reason
841
+ * `pixelRatio` is: a static `import { Dimensions } from 'react-native'` in
842
+ * this non-React core module breaks the vitest suite and risks the
843
+ * Metro/Hermes `unknownModuleError`. It is a PROVIDER, not a value, so a
844
+ * rotation is picked up on the next snapshot with nothing to keep in sync.
845
+ *
846
+ * `null` means the window bounds are UNKNOWN, and {@link computeVisibility}
847
+ * treats unknown bounds as "cannot demote" — never as an empty viewport.
848
+ */
849
+ this.viewportProvider = null;
275
850
  /**
276
851
  * Sticky flag: flips `true` the first time any element is registered and
277
852
  * stays `true` even after elements are unregistered. Lets agents distinguish
@@ -309,6 +884,30 @@ var NativeUIBridgeRegistry = class {
309
884
  getRouteProvider() {
310
885
  return this.routeProvider;
311
886
  }
887
+ /**
888
+ * Resolve the snapshot's `activeTab`: an explicit `getActiveTab()` on the
889
+ * route provider wins, otherwise derive it from the Expo Router segments the
890
+ * provider already exposes.
891
+ *
892
+ * A throwing or empty-string provider degrades to the derivation rather than
893
+ * to `undefined` — the host's opt-in extra should never be able to make the
894
+ * snapshot WORSE than it was without it. A blank derivation stays blank.
895
+ */
896
+ resolveActiveTab(segments) {
897
+ const explicit = (() => {
898
+ if (!this.routeProvider?.getActiveTab) return void 0;
899
+ try {
900
+ const value = this.routeProvider.getActiveTab();
901
+ return typeof value === "string" && value.length > 0 ? value : void 0;
902
+ } catch (error) {
903
+ if (this.config.verbose) {
904
+ console.warn(`[ui-bridge-native] routeProvider getActiveTab threw:`, error);
905
+ }
906
+ return void 0;
907
+ }
908
+ })();
909
+ return explicit ?? deriveActiveTabFromSegments(segments);
910
+ }
312
911
  /**
313
912
  * Set the device pixel ratio used to project per-element `bbox` geometry
314
913
  * into physical pixels. The React provider passes `PixelRatio.get()` here
@@ -324,6 +923,56 @@ var NativeUIBridgeRegistry = class {
324
923
  getPixelRatio() {
325
924
  return this.pixelRatio;
326
925
  }
926
+ /**
927
+ * Set the getter for the device window size (logical dp) that reported
928
+ * visibility is clipped against. The React provider passes
929
+ * `() => Dimensions.get('window')` here at construction.
930
+ *
931
+ * Pass `null` to detach. While detached the window bounds are UNKNOWN and no
932
+ * element is ever demoted for being outside them.
933
+ */
934
+ setViewportProvider(provider) {
935
+ this.viewportProvider = provider ?? null;
936
+ }
937
+ /**
938
+ * The window as a page-space rect, or `null` when it is unknown or the
939
+ * provider returned something unusable (it throws in some RN teardown
940
+ * paths, and a zero-size window would silently hide the entire screen).
941
+ */
942
+ getViewportRect() {
943
+ if (!this.viewportProvider) return null;
944
+ let size;
945
+ try {
946
+ size = this.viewportProvider();
947
+ } catch (error) {
948
+ if (this.config.verbose) {
949
+ console.warn(`[ui-bridge-native] viewportProvider threw:`, error);
950
+ }
951
+ return null;
952
+ }
953
+ const { width, height } = size ?? {};
954
+ if (typeof width !== "number" || typeof height !== "number" || !Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
955
+ return null;
956
+ }
957
+ return { left: 0, top: 0, right: width, bottom: height };
958
+ }
959
+ /**
960
+ * The region `element` must reach to count as on screen: the window,
961
+ * intersected with the measured frame of its declared `scrollAncestorId`.
962
+ *
963
+ * Returns `null` when neither bound is known — see {@link computeVisibility}
964
+ * for why that means "do not demote" rather than "nothing is visible".
965
+ * An ancestor that is not registered, or registered but not yet measured,
966
+ * contributes nothing rather than hiding its subtree.
967
+ */
968
+ getClipRectFor(element, viewport) {
969
+ const bounds = viewport === void 0 ? this.getViewportRect() : viewport;
970
+ const ancestorId = element.scrollAncestorId;
971
+ if (!ancestorId) return bounds;
972
+ const ancestor = this.elements.get(ancestorId);
973
+ if (!ancestor || ancestorId === element.id) return bounds;
974
+ return intersectRects(bounds, pageRectOf(ancestor.getState()));
975
+ }
327
976
  // ============================================================================
328
977
  // Snapshot Enricher Slots
329
978
  // ============================================================================
@@ -367,16 +1016,17 @@ var NativeUIBridgeRegistry = class {
367
1016
  const {
368
1017
  type = "custom",
369
1018
  label,
370
- actions = inferActions(type),
371
1019
  customActions,
372
1020
  props,
373
1021
  treePath = id,
374
1022
  testId,
375
1023
  accessibilityLabel,
376
1024
  registrationRoute,
1025
+ scrollAncestorId,
377
1026
  flatStyle,
378
1027
  stateStyles
379
1028
  } = options;
1029
+ const actions = options.actions ?? inferActions(type, scrollAncestorId);
380
1030
  const getState = () => {
381
1031
  const element = ref.current;
382
1032
  if (!element) {
@@ -419,6 +1069,7 @@ var NativeUIBridgeRegistry = class {
419
1069
  registeredAt: Date.now(),
420
1070
  mounted: true,
421
1071
  registrationRoute: registrationRoute ?? null,
1072
+ scrollAncestorId,
422
1073
  flatStyle,
423
1074
  stateStyles
424
1075
  };
@@ -549,6 +1200,103 @@ var NativeUIBridgeRegistry = class {
549
1200
  console.log(`[ui-bridge-native] Marked ${cleared} elements offscreen for route: ${route}`);
550
1201
  }
551
1202
  }
1203
+ /**
1204
+ * Re-measure every mounted-visible element's geometry via its stored React
1205
+ * ref and write the fresh coordinates into the registry ("measure-on-
1206
+ * snapshot").
1207
+ *
1208
+ * Why: element geometry is otherwise captured ONCE at mount time
1209
+ * (`onLayout` + `measureInWindow` in `useUIElement`) and RN never re-fires
1210
+ * `onLayout` on pure translation — scrolling a virtualized list
1211
+ * (FlashList), recycling a cell, a keyboard shift or an orientation change
1212
+ * all move elements WITHOUT updating the stored `state.layout`. Snapshot
1213
+ * consumers (vision analyzers hit-testing `bbox`, coord-based tap) then
1214
+ * operate on stale positions. Re-measuring at READ time is correct
1215
+ * regardless of what moved the element and needs zero per-consumer wiring:
1216
+ * every registry entry already stores its ref (see `registerElement`).
1217
+ *
1218
+ * Per-element outcomes:
1219
+ * - non-zero dims → write a fresh full layout (same state shape as the
1220
+ * mount-time measure loop in `useUIElement`); counted as `measured`.
1221
+ * - zero dims AND the element previously had a non-null `state.layout` →
1222
+ * the view collapsed / left the screen; clear to
1223
+ * `{ visible: false, layout: null }` (same shape `markRouteOffscreen`
1224
+ * writes); counted as `cleared`.
1225
+ * - zero dims and it never had a layout → leave state untouched (element
1226
+ * in the mount→first-onLayout gap; don't demote `likely-visible`);
1227
+ * counted as `skipped`.
1228
+ * - no ref / no callable `measureInWindow` (test fixtures, web) → leave
1229
+ * untouched; counted as `skipped`.
1230
+ *
1231
+ * The whole sweep races an overall timeout (default 250 ms) so a dead ref
1232
+ * whose `measureInWindow` never calls back can never hang a snapshot: the
1233
+ * returned promise resolves at the deadline with whatever counts have
1234
+ * accumulated. Late callbacks may still write state afterwards — harmless,
1235
+ * freshest data wins. Never throws.
1236
+ *
1237
+ * Non-React core module on purpose — `measureInWindow` is duck-typed off
1238
+ * the stored ref rather than imported from `react-native` (see the
1239
+ * `projectBbox` doc comment for why this file must not import RN).
1240
+ */
1241
+ async refreshMeasurements(options) {
1242
+ const timeoutMs = typeof options?.timeoutMs === "number" && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 250;
1243
+ const counts = { measured: 0, cleared: 0, skipped: 0 };
1244
+ const pending = [];
1245
+ for (const element of this.getMountedVisibleElements()) {
1246
+ const node = element.ref?.current;
1247
+ if (!node || typeof node.measureInWindow !== "function") {
1248
+ counts.skipped++;
1249
+ continue;
1250
+ }
1251
+ const id = element.id;
1252
+ pending.push(
1253
+ new Promise((resolve) => {
1254
+ let settled = false;
1255
+ try {
1256
+ node.measureInWindow((pageX, pageY, w, h) => {
1257
+ if (settled) return;
1258
+ settled = true;
1259
+ try {
1260
+ if (w > 0 && h > 0) {
1261
+ this.updateElementState(id, {
1262
+ mounted: true,
1263
+ visible: true,
1264
+ enabled: true,
1265
+ focused: false,
1266
+ layout: { x: pageX, y: pageY, width: w, height: h, pageX, pageY }
1267
+ });
1268
+ counts.measured++;
1269
+ } else if (this.elements.get(id)?.getState().layout != null) {
1270
+ this.updateElementState(id, { visible: false, layout: null });
1271
+ counts.cleared++;
1272
+ } else {
1273
+ counts.skipped++;
1274
+ }
1275
+ } finally {
1276
+ resolve();
1277
+ }
1278
+ });
1279
+ } catch {
1280
+ if (!settled) {
1281
+ settled = true;
1282
+ counts.skipped++;
1283
+ resolve();
1284
+ }
1285
+ }
1286
+ })
1287
+ );
1288
+ }
1289
+ if (pending.length > 0) {
1290
+ await new Promise((resolve) => {
1291
+ const timer = setTimeout(resolve, timeoutMs);
1292
+ void Promise.all(pending).then(() => {
1293
+ clearTimeout(timer);
1294
+ resolve();
1295
+ });
1296
+ });
1297
+ }
1298
+ return counts;
1299
+ }
552
1300
  /**
553
1301
  * Update element state
554
1302
  */
@@ -698,12 +1446,8 @@ var NativeUIBridgeRegistry = class {
698
1446
  id,
699
1447
  name,
700
1448
  description,
701
- actions: actions.map((a) => ({
702
- id: a.id,
703
- label: a.label,
704
- description: a.description,
705
- handler: a.handler
706
- })),
1449
+ // Closed field list, applied once — see `toRegisteredComponentActions`.
1450
+ actions: toRegisteredComponentActions(actions),
707
1451
  elementIds,
708
1452
  registeredAt: Date.now(),
709
1453
  mounted: true
@@ -715,6 +1459,55 @@ var NativeUIBridgeRegistry = class {
715
1459
  }
716
1460
  return registered;
717
1461
  }
1462
+ /**
1463
+ * Update a registered component's declaration in place — name, description,
1464
+ * actions, elementIds.
1465
+ *
1466
+ * Sister to {@link updateElementMeta}, and it exists for the same reason one
1467
+ * level up. `registerComponent` publishes the declaration exactly once, and
1468
+ * `useUIComponent` registers on mount and never again, so an action whose
1469
+ * `label` carries a count, an action that only exists while a row is
1470
+ * selected, a `paramSchema` that widens once options load, or an `elementIds`
1471
+ * list that grows after mount, all published their mount-time shape to
1472
+ * `/control/components` forever. PR #176 closed that hole for an ELEMENT's
1473
+ * label; this closes it for a COMPONENT's declaration.
1474
+ *
1475
+ * **Idempotent by VALUE over the PUBLISHED shape.** `handler` identity is
1476
+ * excluded from the comparison — call sites pass inline closures that are
1477
+ * freshly allocated every render, so including it would report a change on
1478
+ * every call and make the door useless for a render-path publisher. The
1479
+ * newest handlers are still STORED whenever `actions` is supplied, which is
1480
+ * the other half of the same defect: without it the registry keeps the
1481
+ * closure from the render that registered the component, so a handler reading
1482
+ * component state read the state as it was at mount.
1483
+ *
1484
+ * Returns `true` when the published shape changed and an event was emitted,
1485
+ * `false` otherwise — the same contract as `updateElementMeta`, so a caller
1486
+ * can gate a warning or a state write on a real change.
1487
+ */
1488
+ updateComponentMeta(id, meta) {
1489
+ const component = this.components.get(id);
1490
+ if (!component) return false;
1491
+ const nextName = meta.name !== void 0 ? meta.name : component.name;
1492
+ const nextDescription = meta.description !== void 0 ? meta.description : component.description;
1493
+ const nextActions = meta.actions !== void 0 ? toRegisteredComponentActions(meta.actions) : component.actions;
1494
+ const nextElementIds = meta.elementIds !== void 0 ? meta.elementIds : component.elementIds;
1495
+ const changed = nextName !== component.name || nextDescription !== component.description || !sameElementIds(nextElementIds, component.elementIds) || publishedActionSignature(nextActions) !== publishedActionSignature(component.actions);
1496
+ if (!changed && meta.actions === void 0) return false;
1497
+ this.components.set(id, {
1498
+ ...component,
1499
+ name: nextName,
1500
+ description: nextDescription,
1501
+ actions: nextActions,
1502
+ elementIds: nextElementIds
1503
+ });
1504
+ if (!changed) return false;
1505
+ this.emit("component:registered", { id, name: nextName });
1506
+ if (this.config.verbose) {
1507
+ console.log(`[ui-bridge-native] Updated component: ${id} (${nextName})`);
1508
+ }
1509
+ return true;
1510
+ }
718
1511
  /**
719
1512
  * Unregister a component
720
1513
  */
@@ -828,41 +1621,59 @@ var NativeUIBridgeRegistry = class {
828
1621
  * server-level override (the bare-server / cloud-relay path) still get a
829
1622
  * populated `currentRoute` field as long as `setRouteProvider` was called
830
1623
  * on the registry.
1624
+ *
1625
+ * `activeTab` resolves through {@link resolveActiveTab} on BOTH branches. An
1626
+ * explicit `routeInfo.activeTab` wins outright; otherwise a registered
1627
+ * provider's `getActiveTab()` answers and the Expo Router derivation is the
1628
+ * fallback. Deriving straight from `routeInfo.segments` here instead — which
1629
+ * is what this branch did when `activeTab` was introduced — silently ignored
1630
+ * `RouteProvider.getActiveTab` on the ONLY path that serves
1631
+ * `control/snapshot` once `setRouteProvider` is wired (the server installs a
1632
+ * `getSnapshot` override that passes `routeInfo`), so the opt-in hook for an
1633
+ * app whose visible pane is decoupled from the router reached no consumer.
831
1634
  */
832
1635
  createSnapshot(routeInfo, options) {
833
1636
  const resolvedRoute = (() => {
834
1637
  if (routeInfo !== void 0) {
1638
+ const explicit = typeof routeInfo.activeTab === "string" && routeInfo.activeTab.length > 0 ? routeInfo.activeTab : void 0;
835
1639
  return {
836
1640
  currentRoute: routeInfo.currentRoute ?? null,
837
- segments: routeInfo.segments
1641
+ segments: routeInfo.segments,
1642
+ activeTab: explicit ?? this.resolveActiveTab(routeInfo.segments)
838
1643
  };
839
1644
  }
840
1645
  if (this.routeProvider) {
841
1646
  try {
1647
+ const segments = this.routeProvider.getSegments?.();
842
1648
  return {
843
1649
  currentRoute: this.routeProvider.getCurrentRoute() ?? null,
844
- segments: this.routeProvider.getSegments?.()
1650
+ segments,
1651
+ activeTab: this.resolveActiveTab(segments)
845
1652
  };
846
1653
  } catch (error) {
847
1654
  if (this.config.verbose) {
848
1655
  console.warn(`[ui-bridge-native] routeProvider getCurrentRoute threw:`, error);
849
1656
  }
850
- return { currentRoute: null, segments: void 0 };
1657
+ return { currentRoute: null, segments: void 0, activeTab: void 0 };
851
1658
  }
852
1659
  }
853
- return { currentRoute: null, segments: void 0 };
1660
+ return { currentRoute: null, segments: void 0, activeTab: void 0 };
854
1661
  })();
855
1662
  let elements = options?.visibleOnly ? this.getMountedVisibleElements() : this.getAllElements();
856
1663
  if (options?.currentRouteOnly && resolvedRoute.currentRoute) {
857
1664
  const currentRoute = resolvedRoute.currentRoute;
858
1665
  elements = elements.filter((e) => matchesCurrentRoute(e.registrationRoute, currentRoute));
859
1666
  }
1667
+ const viewport = this.getViewportRect();
860
1668
  const snapshot = {
861
1669
  timestamp: Date.now(),
862
1670
  elements: elements.map((e) => {
863
1671
  const handlers = extractHandlerNames(e.props);
864
1672
  const state = e.getState();
865
- const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
1673
+ const { visibility, visibilityReason } = computeVisibility(
1674
+ state,
1675
+ this.getClipRectFor(e, viewport)
1676
+ );
866
1677
  const bbox = projectBbox(state, visibility, this.pixelRatio);
867
1678
  const vision = projectVisionFields({
868
1679
  type: e.type,
@@ -882,10 +1693,11 @@ var NativeUIBridgeRegistry = class {
882
1693
  identifier: e.getIdentifier(),
883
1694
  state,
884
1695
  actions: e.actions,
885
- customActions: e.customActions ? Object.keys(e.customActions) : void 0,
1696
+ customActions: serializeElementCustomActions(e.customActions),
886
1697
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
887
1698
  registrationRoute: e.registrationRoute,
888
1699
  visibility,
1700
+ ...visibilityReason ? { visibilityReason } : {},
889
1701
  ...bbox ? { bbox } : {},
890
1702
  ...vision
891
1703
  };
@@ -905,6 +1717,13 @@ var NativeUIBridgeRegistry = class {
905
1717
  })),
906
1718
  currentRoute: resolvedRoute.currentRoute,
907
1719
  segments: resolvedRoute.segments,
1720
+ // Canonical aliases, spelled the way the web SDK's `BridgeSnapshot`
1721
+ // spells them so a cross-platform consumer reads one field name on both
1722
+ // platforms. Omitted rather than nulled when unknown — same convention
1723
+ // as the web fields, and it keeps the emitted shape byte-identical for
1724
+ // hosts that wire no route provider.
1725
+ ...resolvedRoute.currentRoute ? { route: resolvedRoute.currentRoute } : {},
1726
+ ...resolvedRoute.activeTab ? { activeTab: resolvedRoute.activeTab } : {},
908
1727
  registration: this.getRegistrationCoverage()
909
1728
  };
910
1729
  if (this.appInfo) {
@@ -1102,6 +1921,6 @@ function matchesIdentifier(identifier, criteria) {
1102
1921
  return true;
1103
1922
  }
1104
1923
 
1105
- export { NativeUIBridgeRegistry, buildTreePath, createNativeElementIdentifier, extractHandlerNames, findAllByPattern, findElementByIdentifier, getGlobalRegistry, matchesCurrentRoute, matchesIdentifier, parseTreePath, projectBbox, resetGlobalRegistry, setGlobalRegistry };
1924
+ export { DEFAULT_PARAM_VALIDATION_MODE, MAX_ACTION_TIMEOUT_MS, NATIVE_STANDARD_ACTION_EFFECTS, NativeUIBridgeRegistry, buildTreePath, computeVisibility, createNativeElementIdentifier, deriveActiveTabFromSegments, extractHandlerNames, findAllByPattern, findElementByIdentifier, formatParamValidationFailure, getDefaultParamValidationMode, getGlobalRegistry, inertAbortSignal, intersectRects, isEmptyRect, matchesCurrentRoute, matchesIdentifier, nativeStandardActionEffect, normalizeActionTimeoutMs, pageRectOf, parseTreePath, projectBbox, resetDefaultParamValidationMode, resetGlobalRegistry, resolveActionEffect, runAbortable, serializeElementCustomActions, setDefaultParamValidationMode, setGlobalRegistry, validateActionParams };
1106
1925
  //# sourceMappingURL=index.mjs.map
1107
1926
  //# sourceMappingURL=index.mjs.map