@qontinui/ui-bridge-native 0.6.12 → 0.6.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -16
- package/dist/control/action-executor.d.ts +42 -2
- package/dist/control/action-executor.d.ts.map +1 -1
- package/dist/control/index.js +723 -4
- package/dist/control/index.js.map +1 -1
- package/dist/control/index.mjs +723 -5
- package/dist/control/index.mjs.map +1 -1
- package/dist/control/types.d.ts +82 -1
- package/dist/control/types.d.ts.map +1 -1
- package/dist/core/abortable.d.ts +151 -0
- package/dist/core/abortable.d.ts.map +1 -0
- package/dist/core/action-effect.d.ts +74 -0
- package/dist/core/action-effect.d.ts.map +1 -0
- package/dist/core/element-actions.d.ts +38 -0
- package/dist/core/element-actions.d.ts.map +1 -0
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +756 -15
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +738 -16
- package/dist/core/index.mjs.map +1 -1
- package/dist/core/param-schema.d.ts +282 -0
- package/dist/core/param-schema.d.ts.map +1 -0
- package/dist/core/registry.d.ts +272 -2
- package/dist/core/registry.d.ts.map +1 -1
- package/dist/core/types.d.ts +231 -32
- package/dist/core/types.d.ts.map +1 -1
- package/dist/debug/index.js.map +1 -1
- package/dist/debug/index.mjs.map +1 -1
- package/dist/index.d.ts +10 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1568 -135
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1550 -136
- package/dist/index.mjs.map +1 -1
- package/dist/react/UIBridgeNativeProvider.d.ts +18 -24
- package/dist/react/UIBridgeNativeProvider.d.ts.map +1 -1
- package/dist/react/index.js +1385 -130
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +1385 -130
- package/dist/react/index.mjs.map +1 -1
- package/dist/react/useUIBridge.d.ts +19 -22
- package/dist/react/useUIBridge.d.ts.map +1 -1
- package/dist/react/useUIComponent.d.ts +113 -8
- package/dist/react/useUIComponent.d.ts.map +1 -1
- package/dist/react/useUIElement.d.ts +34 -8
- package/dist/react/useUIElement.d.ts.map +1 -1
- package/dist/server/handlers.d.ts.map +1 -1
- package/dist/server/http-server.d.ts +15 -2
- package/dist/server/http-server.d.ts.map +1 -1
- package/dist/server/index.js +345 -43
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +345 -44
- package/dist/server/index.mjs.map +1 -1
- package/dist/server/observability.d.ts +40 -0
- package/dist/server/observability.d.ts.map +1 -1
- package/dist/server/types.d.ts +57 -1
- package/dist/server/types.d.ts.map +1 -1
- package/dist/transport/CloudRelayClient.d.ts.map +1 -1
- package/dist/transport/DeviceAnnouncer.d.ts +35 -7
- package/dist/transport/DeviceAnnouncer.d.ts.map +1 -1
- package/dist/transport/index.d.ts +1 -1
- package/dist/transport/index.d.ts.map +1 -1
- package/dist/transport/relay-logging.d.ts +50 -0
- package/dist/transport/relay-logging.d.ts.map +1 -0
- package/package.json +2 -2
package/dist/react/index.js
CHANGED
|
@@ -184,7 +184,42 @@ function projectVisionFields(args) {
|
|
|
184
184
|
return fields;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
// src/core/element-actions.ts
|
|
188
|
+
function serializeElementCustomActions(customActions) {
|
|
189
|
+
if (!customActions) return void 0;
|
|
190
|
+
const out = [];
|
|
191
|
+
for (const [id, action] of Object.entries(customActions)) {
|
|
192
|
+
if (!action) continue;
|
|
193
|
+
const entry = { id };
|
|
194
|
+
if (action.label !== void 0) entry.label = action.label;
|
|
195
|
+
if (action.description !== void 0) entry.description = action.description;
|
|
196
|
+
if (action.effect !== void 0) entry.effect = action.effect;
|
|
197
|
+
out.push(entry);
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
187
202
|
// src/core/registry.ts
|
|
203
|
+
function toRegisteredComponentActions(actions) {
|
|
204
|
+
return actions.map((a) => ({
|
|
205
|
+
id: a.id,
|
|
206
|
+
label: a.label,
|
|
207
|
+
description: a.description,
|
|
208
|
+
paramSchema: a.paramSchema,
|
|
209
|
+
effect: a.effect,
|
|
210
|
+
handler: a.handler
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
function publishedActionSignature(actions) {
|
|
214
|
+
return JSON.stringify(
|
|
215
|
+
actions.map((a) => [a.id, a.label, a.description, a.paramSchema, a.effect])
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
function sameElementIds(a, b) {
|
|
219
|
+
if (a === b) return true;
|
|
220
|
+
if (a === void 0 || b === void 0) return false;
|
|
221
|
+
return a.length === b.length && a.every((id, i) => id === b[i]);
|
|
222
|
+
}
|
|
188
223
|
function extractHandlerNames(props) {
|
|
189
224
|
if (!props) return [];
|
|
190
225
|
return Object.keys(props).filter((k) => typeof props[k] === "function");
|
|
@@ -193,6 +228,22 @@ function matchesCurrentRoute(registrationRoute, currentRoute) {
|
|
|
193
228
|
if (registrationRoute == null || registrationRoute === "") return true;
|
|
194
229
|
return registrationRoute === currentRoute;
|
|
195
230
|
}
|
|
231
|
+
function deriveActiveTabFromSegments(segments) {
|
|
232
|
+
if (!segments || segments.length === 0) return void 0;
|
|
233
|
+
let innermostGroup = -1;
|
|
234
|
+
for (let i = 0; i < segments.length; i++) {
|
|
235
|
+
const segment = segments[i];
|
|
236
|
+
if (typeof segment === "string" && segment.startsWith("(") && segment.endsWith(")")) {
|
|
237
|
+
innermostGroup = i;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (innermostGroup === -1) return void 0;
|
|
241
|
+
const next = segments[innermostGroup + 1];
|
|
242
|
+
if (next === void 0) return "index";
|
|
243
|
+
if (typeof next !== "string" || next.length === 0) return void 0;
|
|
244
|
+
if (next.startsWith("[") && next.endsWith("]")) return void 0;
|
|
245
|
+
return next;
|
|
246
|
+
}
|
|
196
247
|
function projectBbox(state, visibility, pixelRatio = 1) {
|
|
197
248
|
if (visibility !== "visible") return void 0;
|
|
198
249
|
const layout = state.layout;
|
|
@@ -212,8 +263,58 @@ function projectBbox(state, visibility, pixelRatio = 1) {
|
|
|
212
263
|
h: toPx(layout.height)
|
|
213
264
|
};
|
|
214
265
|
}
|
|
215
|
-
function
|
|
216
|
-
const
|
|
266
|
+
function pageRectOf(state) {
|
|
267
|
+
const layout = state.layout;
|
|
268
|
+
if (!layout) return null;
|
|
269
|
+
if (layout.pageOriginUnmeasured === true) return null;
|
|
270
|
+
const hasPage = typeof layout.pageX === "number" && typeof layout.pageY === "number" && Number.isFinite(layout.pageX) && Number.isFinite(layout.pageY);
|
|
271
|
+
if (!hasPage) return null;
|
|
272
|
+
const left = layout.pageX;
|
|
273
|
+
const top = layout.pageY;
|
|
274
|
+
if (typeof layout.width !== "number" || typeof layout.height !== "number" || !Number.isFinite(layout.width) || !Number.isFinite(layout.height)) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
return { left, top, right: left + layout.width, bottom: top + layout.height };
|
|
278
|
+
}
|
|
279
|
+
function spansOverlap(aMin, aMax, bMin, bMax) {
|
|
280
|
+
if (aMin === aMax) return aMin >= bMin && aMin <= bMax;
|
|
281
|
+
return aMin < bMax && aMax > bMin;
|
|
282
|
+
}
|
|
283
|
+
function isEmptyRect(rect) {
|
|
284
|
+
return rect.right <= rect.left || rect.bottom <= rect.top;
|
|
285
|
+
}
|
|
286
|
+
function intersectRects(a, b) {
|
|
287
|
+
if (!a) return b;
|
|
288
|
+
if (!b) return a;
|
|
289
|
+
return {
|
|
290
|
+
left: Math.max(a.left, b.left),
|
|
291
|
+
top: Math.max(a.top, b.top),
|
|
292
|
+
right: Math.min(a.right, b.right),
|
|
293
|
+
bottom: Math.min(a.bottom, b.bottom)
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function computeVisibility(state, clip) {
|
|
297
|
+
if (!state.visible) {
|
|
298
|
+
return { visibility: "hidden", visibilityReason: "hidden" };
|
|
299
|
+
}
|
|
300
|
+
if (state.layout === null) {
|
|
301
|
+
return { visibility: "likely-visible", visibilityReason: "no-layout" };
|
|
302
|
+
}
|
|
303
|
+
if (!clip) {
|
|
304
|
+
return { visibility: "visible" };
|
|
305
|
+
}
|
|
306
|
+
const rect = pageRectOf(state);
|
|
307
|
+
if (!rect) {
|
|
308
|
+
return { visibility: "visible" };
|
|
309
|
+
}
|
|
310
|
+
if (isEmptyRect(clip)) {
|
|
311
|
+
return { visibility: "hidden", visibilityReason: "off-screen" };
|
|
312
|
+
}
|
|
313
|
+
const onScreen = spansOverlap(rect.left, rect.right, clip.left, clip.right) && spansOverlap(rect.top, rect.bottom, clip.top, clip.bottom);
|
|
314
|
+
return onScreen ? { visibility: "visible" } : { visibility: "hidden", visibilityReason: "off-screen" };
|
|
315
|
+
}
|
|
316
|
+
function inferActions(type, scrollAncestorId) {
|
|
317
|
+
const baseActions = scrollAncestorId ? ["focus", "blur", "scrollIntoView"] : ["focus", "blur"];
|
|
217
318
|
switch (type) {
|
|
218
319
|
case "button":
|
|
219
320
|
case "touchable":
|
|
@@ -285,6 +386,18 @@ var NativeUIBridgeRegistry = class {
|
|
|
285
386
|
* the `projectBbox` doc comment for why we don't import `react-native` here.
|
|
286
387
|
*/
|
|
287
388
|
this.pixelRatio = 1;
|
|
389
|
+
/**
|
|
390
|
+
* Injected getter for the device window size in logical dp, used to clip
|
|
391
|
+
* reported visibility. Injected rather than read here for exactly the reason
|
|
392
|
+
* `pixelRatio` is: a static `import { Dimensions } from 'react-native'` in
|
|
393
|
+
* this non-React core module breaks the vitest suite and risks the
|
|
394
|
+
* Metro/Hermes `unknownModuleError`. It is a PROVIDER, not a value, so a
|
|
395
|
+
* rotation is picked up on the next snapshot with nothing to keep in sync.
|
|
396
|
+
*
|
|
397
|
+
* `null` means the window bounds are UNKNOWN, and {@link computeVisibility}
|
|
398
|
+
* treats unknown bounds as "cannot demote" — never as an empty viewport.
|
|
399
|
+
*/
|
|
400
|
+
this.viewportProvider = null;
|
|
288
401
|
/**
|
|
289
402
|
* Sticky flag: flips `true` the first time any element is registered and
|
|
290
403
|
* stays `true` even after elements are unregistered. Lets agents distinguish
|
|
@@ -322,6 +435,30 @@ var NativeUIBridgeRegistry = class {
|
|
|
322
435
|
getRouteProvider() {
|
|
323
436
|
return this.routeProvider;
|
|
324
437
|
}
|
|
438
|
+
/**
|
|
439
|
+
* Resolve the snapshot's `activeTab`: an explicit `getActiveTab()` on the
|
|
440
|
+
* route provider wins, otherwise derive it from the Expo Router segments the
|
|
441
|
+
* provider already exposes.
|
|
442
|
+
*
|
|
443
|
+
* A throwing or empty-string provider degrades to the derivation rather than
|
|
444
|
+
* to `undefined` — the host's opt-in extra should never be able to make the
|
|
445
|
+
* snapshot WORSE than it was without it. A blank derivation stays blank.
|
|
446
|
+
*/
|
|
447
|
+
resolveActiveTab(segments) {
|
|
448
|
+
const explicit = (() => {
|
|
449
|
+
if (!this.routeProvider?.getActiveTab) return void 0;
|
|
450
|
+
try {
|
|
451
|
+
const value = this.routeProvider.getActiveTab();
|
|
452
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
453
|
+
} catch (error3) {
|
|
454
|
+
if (this.config.verbose) {
|
|
455
|
+
console.warn(`[ui-bridge-native] routeProvider getActiveTab threw:`, error3);
|
|
456
|
+
}
|
|
457
|
+
return void 0;
|
|
458
|
+
}
|
|
459
|
+
})();
|
|
460
|
+
return explicit ?? deriveActiveTabFromSegments(segments);
|
|
461
|
+
}
|
|
325
462
|
/**
|
|
326
463
|
* Set the device pixel ratio used to project per-element `bbox` geometry
|
|
327
464
|
* into physical pixels. The React provider passes `PixelRatio.get()` here
|
|
@@ -337,6 +474,56 @@ var NativeUIBridgeRegistry = class {
|
|
|
337
474
|
getPixelRatio() {
|
|
338
475
|
return this.pixelRatio;
|
|
339
476
|
}
|
|
477
|
+
/**
|
|
478
|
+
* Set the getter for the device window size (logical dp) that reported
|
|
479
|
+
* visibility is clipped against. The React provider passes
|
|
480
|
+
* `() => Dimensions.get('window')` here at construction.
|
|
481
|
+
*
|
|
482
|
+
* Pass `null` to detach. While detached the window bounds are UNKNOWN and no
|
|
483
|
+
* element is ever demoted for being outside them.
|
|
484
|
+
*/
|
|
485
|
+
setViewportProvider(provider) {
|
|
486
|
+
this.viewportProvider = provider ?? null;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* The window as a page-space rect, or `null` when it is unknown or the
|
|
490
|
+
* provider returned something unusable (it throws in some RN teardown
|
|
491
|
+
* paths, and a zero-size window would silently hide the entire screen).
|
|
492
|
+
*/
|
|
493
|
+
getViewportRect() {
|
|
494
|
+
if (!this.viewportProvider) return null;
|
|
495
|
+
let size;
|
|
496
|
+
try {
|
|
497
|
+
size = this.viewportProvider();
|
|
498
|
+
} catch (error3) {
|
|
499
|
+
if (this.config.verbose) {
|
|
500
|
+
console.warn(`[ui-bridge-native] viewportProvider threw:`, error3);
|
|
501
|
+
}
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
const { width, height } = size ?? {};
|
|
505
|
+
if (typeof width !== "number" || typeof height !== "number" || !Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
return { left: 0, top: 0, right: width, bottom: height };
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* The region `element` must reach to count as on screen: the window,
|
|
512
|
+
* intersected with the measured frame of its declared `scrollAncestorId`.
|
|
513
|
+
*
|
|
514
|
+
* Returns `null` when neither bound is known — see {@link computeVisibility}
|
|
515
|
+
* for why that means "do not demote" rather than "nothing is visible".
|
|
516
|
+
* An ancestor that is not registered, or registered but not yet measured,
|
|
517
|
+
* contributes nothing rather than hiding its subtree.
|
|
518
|
+
*/
|
|
519
|
+
getClipRectFor(element, viewport) {
|
|
520
|
+
const bounds = viewport === void 0 ? this.getViewportRect() : viewport;
|
|
521
|
+
const ancestorId = element.scrollAncestorId;
|
|
522
|
+
if (!ancestorId) return bounds;
|
|
523
|
+
const ancestor = this.elements.get(ancestorId);
|
|
524
|
+
if (!ancestor || ancestorId === element.id) return bounds;
|
|
525
|
+
return intersectRects(bounds, pageRectOf(ancestor.getState()));
|
|
526
|
+
}
|
|
340
527
|
// ============================================================================
|
|
341
528
|
// Snapshot Enricher Slots
|
|
342
529
|
// ============================================================================
|
|
@@ -380,16 +567,17 @@ var NativeUIBridgeRegistry = class {
|
|
|
380
567
|
const {
|
|
381
568
|
type = "custom",
|
|
382
569
|
label,
|
|
383
|
-
actions = inferActions(type),
|
|
384
570
|
customActions,
|
|
385
571
|
props,
|
|
386
572
|
treePath = id,
|
|
387
573
|
testId,
|
|
388
574
|
accessibilityLabel,
|
|
389
575
|
registrationRoute,
|
|
576
|
+
scrollAncestorId,
|
|
390
577
|
flatStyle,
|
|
391
578
|
stateStyles
|
|
392
579
|
} = options;
|
|
580
|
+
const actions = options.actions ?? inferActions(type, scrollAncestorId);
|
|
393
581
|
const getState = () => {
|
|
394
582
|
const element = ref.current;
|
|
395
583
|
if (!element) {
|
|
@@ -432,6 +620,7 @@ var NativeUIBridgeRegistry = class {
|
|
|
432
620
|
registeredAt: Date.now(),
|
|
433
621
|
mounted: true,
|
|
434
622
|
registrationRoute: registrationRoute ?? null,
|
|
623
|
+
scrollAncestorId,
|
|
435
624
|
flatStyle,
|
|
436
625
|
stateStyles
|
|
437
626
|
};
|
|
@@ -808,12 +997,8 @@ var NativeUIBridgeRegistry = class {
|
|
|
808
997
|
id,
|
|
809
998
|
name,
|
|
810
999
|
description,
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
label: a.label,
|
|
814
|
-
description: a.description,
|
|
815
|
-
handler: a.handler
|
|
816
|
-
})),
|
|
1000
|
+
// Closed field list, applied once — see `toRegisteredComponentActions`.
|
|
1001
|
+
actions: toRegisteredComponentActions(actions),
|
|
817
1002
|
elementIds,
|
|
818
1003
|
registeredAt: Date.now(),
|
|
819
1004
|
mounted: true
|
|
@@ -825,6 +1010,55 @@ var NativeUIBridgeRegistry = class {
|
|
|
825
1010
|
}
|
|
826
1011
|
return registered;
|
|
827
1012
|
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Update a registered component's declaration in place — name, description,
|
|
1015
|
+
* actions, elementIds.
|
|
1016
|
+
*
|
|
1017
|
+
* Sister to {@link updateElementMeta}, and it exists for the same reason one
|
|
1018
|
+
* level up. `registerComponent` publishes the declaration exactly once, and
|
|
1019
|
+
* `useUIComponent` registers on mount and never again, so an action whose
|
|
1020
|
+
* `label` carries a count, an action that only exists while a row is
|
|
1021
|
+
* selected, a `paramSchema` that widens once options load, or an `elementIds`
|
|
1022
|
+
* list that grows after mount, all published their mount-time shape to
|
|
1023
|
+
* `/control/components` forever. PR #176 closed that hole for an ELEMENT's
|
|
1024
|
+
* label; this closes it for a COMPONENT's declaration.
|
|
1025
|
+
*
|
|
1026
|
+
* **Idempotent by VALUE over the PUBLISHED shape.** `handler` identity is
|
|
1027
|
+
* excluded from the comparison — call sites pass inline closures that are
|
|
1028
|
+
* freshly allocated every render, so including it would report a change on
|
|
1029
|
+
* every call and make the door useless for a render-path publisher. The
|
|
1030
|
+
* newest handlers are still STORED whenever `actions` is supplied, which is
|
|
1031
|
+
* the other half of the same defect: without it the registry keeps the
|
|
1032
|
+
* closure from the render that registered the component, so a handler reading
|
|
1033
|
+
* component state read the state as it was at mount.
|
|
1034
|
+
*
|
|
1035
|
+
* Returns `true` when the published shape changed and an event was emitted,
|
|
1036
|
+
* `false` otherwise — the same contract as `updateElementMeta`, so a caller
|
|
1037
|
+
* can gate a warning or a state write on a real change.
|
|
1038
|
+
*/
|
|
1039
|
+
updateComponentMeta(id, meta) {
|
|
1040
|
+
const component = this.components.get(id);
|
|
1041
|
+
if (!component) return false;
|
|
1042
|
+
const nextName = meta.name !== void 0 ? meta.name : component.name;
|
|
1043
|
+
const nextDescription = meta.description !== void 0 ? meta.description : component.description;
|
|
1044
|
+
const nextActions = meta.actions !== void 0 ? toRegisteredComponentActions(meta.actions) : component.actions;
|
|
1045
|
+
const nextElementIds = meta.elementIds !== void 0 ? meta.elementIds : component.elementIds;
|
|
1046
|
+
const changed = nextName !== component.name || nextDescription !== component.description || !sameElementIds(nextElementIds, component.elementIds) || publishedActionSignature(nextActions) !== publishedActionSignature(component.actions);
|
|
1047
|
+
if (!changed && meta.actions === void 0) return false;
|
|
1048
|
+
this.components.set(id, {
|
|
1049
|
+
...component,
|
|
1050
|
+
name: nextName,
|
|
1051
|
+
description: nextDescription,
|
|
1052
|
+
actions: nextActions,
|
|
1053
|
+
elementIds: nextElementIds
|
|
1054
|
+
});
|
|
1055
|
+
if (!changed) return false;
|
|
1056
|
+
this.emit("component:registered", { id, name: nextName });
|
|
1057
|
+
if (this.config.verbose) {
|
|
1058
|
+
console.log(`[ui-bridge-native] Updated component: ${id} (${nextName})`);
|
|
1059
|
+
}
|
|
1060
|
+
return true;
|
|
1061
|
+
}
|
|
828
1062
|
/**
|
|
829
1063
|
* Unregister a component
|
|
830
1064
|
*/
|
|
@@ -938,41 +1172,59 @@ var NativeUIBridgeRegistry = class {
|
|
|
938
1172
|
* server-level override (the bare-server / cloud-relay path) still get a
|
|
939
1173
|
* populated `currentRoute` field as long as `setRouteProvider` was called
|
|
940
1174
|
* on the registry.
|
|
1175
|
+
*
|
|
1176
|
+
* `activeTab` resolves through {@link resolveActiveTab} on BOTH branches. An
|
|
1177
|
+
* explicit `routeInfo.activeTab` wins outright; otherwise a registered
|
|
1178
|
+
* provider's `getActiveTab()` answers and the Expo Router derivation is the
|
|
1179
|
+
* fallback. Deriving straight from `routeInfo.segments` here instead — which
|
|
1180
|
+
* is what this branch did when `activeTab` was introduced — silently ignored
|
|
1181
|
+
* `RouteProvider.getActiveTab` on the ONLY path that serves
|
|
1182
|
+
* `control/snapshot` once `setRouteProvider` is wired (the server installs a
|
|
1183
|
+
* `getSnapshot` override that passes `routeInfo`), so the opt-in hook for an
|
|
1184
|
+
* app whose visible pane is decoupled from the router reached no consumer.
|
|
941
1185
|
*/
|
|
942
1186
|
createSnapshot(routeInfo, options) {
|
|
943
1187
|
const resolvedRoute = (() => {
|
|
944
1188
|
if (routeInfo !== void 0) {
|
|
1189
|
+
const explicit = typeof routeInfo.activeTab === "string" && routeInfo.activeTab.length > 0 ? routeInfo.activeTab : void 0;
|
|
945
1190
|
return {
|
|
946
1191
|
currentRoute: routeInfo.currentRoute ?? null,
|
|
947
|
-
segments: routeInfo.segments
|
|
1192
|
+
segments: routeInfo.segments,
|
|
1193
|
+
activeTab: explicit ?? this.resolveActiveTab(routeInfo.segments)
|
|
948
1194
|
};
|
|
949
1195
|
}
|
|
950
1196
|
if (this.routeProvider) {
|
|
951
1197
|
try {
|
|
1198
|
+
const segments = this.routeProvider.getSegments?.();
|
|
952
1199
|
return {
|
|
953
1200
|
currentRoute: this.routeProvider.getCurrentRoute() ?? null,
|
|
954
|
-
segments
|
|
1201
|
+
segments,
|
|
1202
|
+
activeTab: this.resolveActiveTab(segments)
|
|
955
1203
|
};
|
|
956
1204
|
} catch (error3) {
|
|
957
1205
|
if (this.config.verbose) {
|
|
958
1206
|
console.warn(`[ui-bridge-native] routeProvider getCurrentRoute threw:`, error3);
|
|
959
1207
|
}
|
|
960
|
-
return { currentRoute: null, segments: void 0 };
|
|
1208
|
+
return { currentRoute: null, segments: void 0, activeTab: void 0 };
|
|
961
1209
|
}
|
|
962
1210
|
}
|
|
963
|
-
return { currentRoute: null, segments: void 0 };
|
|
1211
|
+
return { currentRoute: null, segments: void 0, activeTab: void 0 };
|
|
964
1212
|
})();
|
|
965
1213
|
let elements = options?.visibleOnly ? this.getMountedVisibleElements() : this.getAllElements();
|
|
966
1214
|
if (options?.currentRouteOnly && resolvedRoute.currentRoute) {
|
|
967
1215
|
const currentRoute = resolvedRoute.currentRoute;
|
|
968
1216
|
elements = elements.filter((e) => matchesCurrentRoute(e.registrationRoute, currentRoute));
|
|
969
1217
|
}
|
|
1218
|
+
const viewport = this.getViewportRect();
|
|
970
1219
|
const snapshot = {
|
|
971
1220
|
timestamp: Date.now(),
|
|
972
1221
|
elements: elements.map((e) => {
|
|
973
1222
|
const handlers = extractHandlerNames(e.props);
|
|
974
1223
|
const state = e.getState();
|
|
975
|
-
const visibility
|
|
1224
|
+
const { visibility, visibilityReason } = computeVisibility(
|
|
1225
|
+
state,
|
|
1226
|
+
this.getClipRectFor(e, viewport)
|
|
1227
|
+
);
|
|
976
1228
|
const bbox = projectBbox(state, visibility, this.pixelRatio);
|
|
977
1229
|
const vision = projectVisionFields({
|
|
978
1230
|
type: e.type,
|
|
@@ -992,10 +1244,11 @@ var NativeUIBridgeRegistry = class {
|
|
|
992
1244
|
identifier: e.getIdentifier(),
|
|
993
1245
|
state,
|
|
994
1246
|
actions: e.actions,
|
|
995
|
-
customActions:
|
|
1247
|
+
customActions: serializeElementCustomActions(e.customActions),
|
|
996
1248
|
registeredHandlers: handlers.length > 0 ? handlers : void 0,
|
|
997
1249
|
registrationRoute: e.registrationRoute,
|
|
998
1250
|
visibility,
|
|
1251
|
+
...visibilityReason ? { visibilityReason } : {},
|
|
999
1252
|
...bbox ? { bbox } : {},
|
|
1000
1253
|
...vision
|
|
1001
1254
|
};
|
|
@@ -1015,6 +1268,13 @@ var NativeUIBridgeRegistry = class {
|
|
|
1015
1268
|
})),
|
|
1016
1269
|
currentRoute: resolvedRoute.currentRoute,
|
|
1017
1270
|
segments: resolvedRoute.segments,
|
|
1271
|
+
// Canonical aliases, spelled the way the web SDK's `BridgeSnapshot`
|
|
1272
|
+
// spells them so a cross-platform consumer reads one field name on both
|
|
1273
|
+
// platforms. Omitted rather than nulled when unknown — same convention
|
|
1274
|
+
// as the web fields, and it keeps the emitted shape byte-identical for
|
|
1275
|
+
// hosts that wire no route provider.
|
|
1276
|
+
...resolvedRoute.currentRoute ? { route: resolvedRoute.currentRoute } : {},
|
|
1277
|
+
...resolvedRoute.activeTab ? { activeTab: resolvedRoute.activeTab } : {},
|
|
1018
1278
|
registration: this.getRegistrationCoverage()
|
|
1019
1279
|
};
|
|
1020
1280
|
if (this.appInfo) {
|
|
@@ -1160,6 +1420,437 @@ function findByPattern(registry, pattern) {
|
|
|
1160
1420
|
return null;
|
|
1161
1421
|
}
|
|
1162
1422
|
|
|
1423
|
+
// src/core/abortable.ts
|
|
1424
|
+
var MAX_ACTION_TIMEOUT_MS = 864e5;
|
|
1425
|
+
function normalizeActionTimeoutMs(raw) {
|
|
1426
|
+
if (raw === void 0 || raw === null) {
|
|
1427
|
+
return { ok: true, timeoutMs: void 0, clamped: false };
|
|
1428
|
+
}
|
|
1429
|
+
if (typeof raw !== "number") {
|
|
1430
|
+
return {
|
|
1431
|
+
ok: false,
|
|
1432
|
+
reason: `timeoutMs must be a number of milliseconds, received ${typeof raw === "string" ? JSON.stringify(raw) : typeof raw}`
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
if (!Number.isFinite(raw)) {
|
|
1436
|
+
return {
|
|
1437
|
+
ok: false,
|
|
1438
|
+
reason: `timeoutMs must be a finite number of milliseconds, received ${String(raw)}`
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
if (raw < 0) {
|
|
1442
|
+
return {
|
|
1443
|
+
ok: false,
|
|
1444
|
+
reason: `timeoutMs must not be negative, received ${String(raw)}`
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
if (raw > MAX_ACTION_TIMEOUT_MS) {
|
|
1448
|
+
return { ok: true, timeoutMs: MAX_ACTION_TIMEOUT_MS, clamped: true };
|
|
1449
|
+
}
|
|
1450
|
+
return { ok: true, timeoutMs: Math.floor(raw), clamped: false };
|
|
1451
|
+
}
|
|
1452
|
+
function inertAbortSignal() {
|
|
1453
|
+
return new AbortController().signal;
|
|
1454
|
+
}
|
|
1455
|
+
async function runAbortable(invoke, options = {}) {
|
|
1456
|
+
const callerSignal = options.signal;
|
|
1457
|
+
const timeoutMs = options.timeoutMs;
|
|
1458
|
+
if (callerSignal?.aborted) {
|
|
1459
|
+
return { aborted: true, reason: "signal" };
|
|
1460
|
+
}
|
|
1461
|
+
const controller = new AbortController();
|
|
1462
|
+
let reason = "signal";
|
|
1463
|
+
let timer;
|
|
1464
|
+
const onCallerAbort = () => {
|
|
1465
|
+
reason = "signal";
|
|
1466
|
+
controller.abort();
|
|
1467
|
+
};
|
|
1468
|
+
callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
1469
|
+
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs >= 0) {
|
|
1470
|
+
timer = setTimeout(
|
|
1471
|
+
() => {
|
|
1472
|
+
reason = "timeout";
|
|
1473
|
+
controller.abort();
|
|
1474
|
+
},
|
|
1475
|
+
Math.min(timeoutMs, MAX_ACTION_TIMEOUT_MS)
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
try {
|
|
1479
|
+
const abandoned = new Promise((resolve) => {
|
|
1480
|
+
controller.signal.addEventListener("abort", () => resolve({ aborted: true, reason }), {
|
|
1481
|
+
once: true
|
|
1482
|
+
});
|
|
1483
|
+
});
|
|
1484
|
+
const completed = (async () => ({
|
|
1485
|
+
aborted: false,
|
|
1486
|
+
result: await invoke(controller.signal)
|
|
1487
|
+
}))();
|
|
1488
|
+
return await Promise.race([completed, abandoned]);
|
|
1489
|
+
} finally {
|
|
1490
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1491
|
+
callerSignal?.removeEventListener("abort", onCallerAbort);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// src/core/param-schema.ts
|
|
1496
|
+
var DEFAULT_PARAM_VALIDATION_MODE = "warn";
|
|
1497
|
+
var currentDefaultMode = DEFAULT_PARAM_VALIDATION_MODE;
|
|
1498
|
+
function getDefaultParamValidationMode() {
|
|
1499
|
+
return currentDefaultMode;
|
|
1500
|
+
}
|
|
1501
|
+
var PRIMITIVE_TYPE_NAMES = /* @__PURE__ */ new Set([
|
|
1502
|
+
"string",
|
|
1503
|
+
"number",
|
|
1504
|
+
"integer",
|
|
1505
|
+
"boolean",
|
|
1506
|
+
"object",
|
|
1507
|
+
"array",
|
|
1508
|
+
"null"
|
|
1509
|
+
]);
|
|
1510
|
+
var OBJECT_SCHEMA_KEYWORDS = /* @__PURE__ */ new Set([
|
|
1511
|
+
"type",
|
|
1512
|
+
"properties",
|
|
1513
|
+
"required",
|
|
1514
|
+
"additionalProperties",
|
|
1515
|
+
"patternProperties",
|
|
1516
|
+
"propertyNames",
|
|
1517
|
+
"minProperties",
|
|
1518
|
+
"maxProperties",
|
|
1519
|
+
"dependentRequired",
|
|
1520
|
+
"dependentSchemas",
|
|
1521
|
+
"unevaluatedProperties",
|
|
1522
|
+
"allOf",
|
|
1523
|
+
"anyOf",
|
|
1524
|
+
"oneOf",
|
|
1525
|
+
"not",
|
|
1526
|
+
"if",
|
|
1527
|
+
"then",
|
|
1528
|
+
"else",
|
|
1529
|
+
"enum",
|
|
1530
|
+
"const",
|
|
1531
|
+
"default",
|
|
1532
|
+
"examples",
|
|
1533
|
+
"title",
|
|
1534
|
+
"description",
|
|
1535
|
+
"deprecated",
|
|
1536
|
+
"readOnly",
|
|
1537
|
+
"writeOnly",
|
|
1538
|
+
"$schema",
|
|
1539
|
+
"$id",
|
|
1540
|
+
"$ref",
|
|
1541
|
+
"$comment",
|
|
1542
|
+
"$defs",
|
|
1543
|
+
"definitions"
|
|
1544
|
+
]);
|
|
1545
|
+
var MAX_SCHEMA_DEPTH = 32;
|
|
1546
|
+
var MAX_PATTERN_LENGTH = 200;
|
|
1547
|
+
function hasNestedQuantifiers(pattern) {
|
|
1548
|
+
return /(\((?:[^()]*[+*}])[^()]*\))[+*?]|\(\?:[^()]*[+*}][^()]*\)[+*?]/.test(pattern);
|
|
1549
|
+
}
|
|
1550
|
+
function isPlainObject(value) {
|
|
1551
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1552
|
+
}
|
|
1553
|
+
var hasOwnProperty = Object.prototype.hasOwnProperty;
|
|
1554
|
+
function hasOwn(value, key) {
|
|
1555
|
+
return hasOwnProperty.call(value, key);
|
|
1556
|
+
}
|
|
1557
|
+
function render(value) {
|
|
1558
|
+
if (value === null) return "null";
|
|
1559
|
+
if (value === void 0) return "undefined";
|
|
1560
|
+
if (Array.isArray(value)) return `array(${value.length})`;
|
|
1561
|
+
const t = typeof value;
|
|
1562
|
+
if (t === "string") return JSON.stringify(value);
|
|
1563
|
+
if (t === "number" || t === "boolean") return String(value);
|
|
1564
|
+
if (t === "object") return "object";
|
|
1565
|
+
return t;
|
|
1566
|
+
}
|
|
1567
|
+
var MAX_RENDERED_JSON_LENGTH = 120;
|
|
1568
|
+
function renderJson(value) {
|
|
1569
|
+
if (value === void 0) return "undefined";
|
|
1570
|
+
let text;
|
|
1571
|
+
try {
|
|
1572
|
+
text = JSON.stringify(value) ?? String(value);
|
|
1573
|
+
} catch {
|
|
1574
|
+
return render(value);
|
|
1575
|
+
}
|
|
1576
|
+
return text.length > MAX_RENDERED_JSON_LENGTH ? `${text.slice(0, MAX_RENDERED_JSON_LENGTH)}\u2026` : text;
|
|
1577
|
+
}
|
|
1578
|
+
function jsonTypeOf(value) {
|
|
1579
|
+
if (value === null) return "null";
|
|
1580
|
+
if (Array.isArray(value)) return "array";
|
|
1581
|
+
const t = typeof value;
|
|
1582
|
+
if (t === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
1583
|
+
if (t === "string" || t === "boolean" || t === "object") return t;
|
|
1584
|
+
return t;
|
|
1585
|
+
}
|
|
1586
|
+
function matchesType(value, typeName) {
|
|
1587
|
+
switch (typeName) {
|
|
1588
|
+
case "string":
|
|
1589
|
+
return typeof value === "string";
|
|
1590
|
+
case "number":
|
|
1591
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
1592
|
+
case "integer":
|
|
1593
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
1594
|
+
case "boolean":
|
|
1595
|
+
return typeof value === "boolean";
|
|
1596
|
+
case "null":
|
|
1597
|
+
return value === null;
|
|
1598
|
+
case "array":
|
|
1599
|
+
return Array.isArray(value);
|
|
1600
|
+
case "object":
|
|
1601
|
+
return isPlainObject(value);
|
|
1602
|
+
default:
|
|
1603
|
+
return true;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
function jsonEquals(a, b, depth = 0) {
|
|
1607
|
+
if (a === b) return true;
|
|
1608
|
+
if (depth > MAX_SCHEMA_DEPTH) return false;
|
|
1609
|
+
if (a === null || b === null || a === void 0 || b === void 0) return false;
|
|
1610
|
+
if (typeof a !== typeof b) return false;
|
|
1611
|
+
if (typeof a !== "object") return false;
|
|
1612
|
+
const aIsArray = Array.isArray(a);
|
|
1613
|
+
if (aIsArray !== Array.isArray(b)) return false;
|
|
1614
|
+
if (aIsArray) {
|
|
1615
|
+
const bArr = b;
|
|
1616
|
+
const aArr = a;
|
|
1617
|
+
if (aArr.length !== bArr.length) return false;
|
|
1618
|
+
return aArr.every((item, i) => jsonEquals(item, bArr[i], depth + 1));
|
|
1619
|
+
}
|
|
1620
|
+
const aObj = a;
|
|
1621
|
+
const bObj = b;
|
|
1622
|
+
const aKeys = Object.keys(aObj);
|
|
1623
|
+
const bKeys = Object.keys(bObj);
|
|
1624
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
1625
|
+
return aKeys.every((key) => hasOwn(bObj, key) && jsonEquals(aObj[key], bObj[key], depth + 1));
|
|
1626
|
+
}
|
|
1627
|
+
function joinPath(base, key) {
|
|
1628
|
+
return base === "" ? key : `${base}.${key}`;
|
|
1629
|
+
}
|
|
1630
|
+
function readTypes(schema) {
|
|
1631
|
+
const raw = schema["type"];
|
|
1632
|
+
if (typeof raw === "string") return [raw];
|
|
1633
|
+
if (Array.isArray(raw)) {
|
|
1634
|
+
const names = raw.filter((t) => typeof t === "string");
|
|
1635
|
+
return names.length > 0 ? names : null;
|
|
1636
|
+
}
|
|
1637
|
+
return null;
|
|
1638
|
+
}
|
|
1639
|
+
function validateValue(value, schema, path, issues, depth) {
|
|
1640
|
+
if (depth > MAX_SCHEMA_DEPTH) return;
|
|
1641
|
+
if (typeof schema === "string") {
|
|
1642
|
+
if (PRIMITIVE_TYPE_NAMES.has(schema) && !matchesType(value, schema)) {
|
|
1643
|
+
issues.push({
|
|
1644
|
+
path,
|
|
1645
|
+
keyword: "type",
|
|
1646
|
+
expected: schema,
|
|
1647
|
+
received: render(value),
|
|
1648
|
+
message: `Parameter "${path}" must be of type ${schema}, received ${jsonTypeOf(value)} (${render(value)}).`
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
if (!isPlainObject(schema)) return;
|
|
1654
|
+
const types = readTypes(schema);
|
|
1655
|
+
if (types !== null && !types.some((t) => matchesType(value, t))) {
|
|
1656
|
+
const expected = types.join(" | ");
|
|
1657
|
+
issues.push({
|
|
1658
|
+
path,
|
|
1659
|
+
keyword: "type",
|
|
1660
|
+
expected,
|
|
1661
|
+
received: render(value),
|
|
1662
|
+
message: `Parameter "${path}" must be of type ${expected}, received ${jsonTypeOf(value)} (${render(value)}).`
|
|
1663
|
+
});
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
if (hasOwn(schema, "const") && !jsonEquals(value, schema["const"])) {
|
|
1667
|
+
const expected = renderJson(schema["const"]);
|
|
1668
|
+
const received = renderJson(value);
|
|
1669
|
+
issues.push({
|
|
1670
|
+
path,
|
|
1671
|
+
keyword: "const",
|
|
1672
|
+
expected,
|
|
1673
|
+
received,
|
|
1674
|
+
message: `Parameter "${path}" must equal ${expected}, received ${received}.`
|
|
1675
|
+
});
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
const enumValues = schema["enum"];
|
|
1679
|
+
if (Array.isArray(enumValues) && !enumValues.some((allowed) => jsonEquals(value, allowed))) {
|
|
1680
|
+
const expected = enumValues.map((v) => renderJson(v)).join(", ");
|
|
1681
|
+
const received = renderJson(value);
|
|
1682
|
+
issues.push({
|
|
1683
|
+
path,
|
|
1684
|
+
keyword: "enum",
|
|
1685
|
+
expected,
|
|
1686
|
+
received,
|
|
1687
|
+
message: `Parameter "${path}" must be one of [${expected}], received ${received}.`
|
|
1688
|
+
});
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
if (typeof value === "number") {
|
|
1692
|
+
const min = schema["minimum"];
|
|
1693
|
+
if (typeof min === "number" && value < min) {
|
|
1694
|
+
issues.push({
|
|
1695
|
+
path,
|
|
1696
|
+
keyword: "minimum",
|
|
1697
|
+
expected: `>= ${min}`,
|
|
1698
|
+
received: render(value),
|
|
1699
|
+
message: `Parameter "${path}" must be >= ${min}, received ${value}.`
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
const max = schema["maximum"];
|
|
1703
|
+
if (typeof max === "number" && value > max) {
|
|
1704
|
+
issues.push({
|
|
1705
|
+
path,
|
|
1706
|
+
keyword: "maximum",
|
|
1707
|
+
expected: `<= ${max}`,
|
|
1708
|
+
received: render(value),
|
|
1709
|
+
message: `Parameter "${path}" must be <= ${max}, received ${value}.`
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
if (typeof value === "string") {
|
|
1714
|
+
const minLength = schema["minLength"];
|
|
1715
|
+
if (typeof minLength === "number" && value.length < minLength) {
|
|
1716
|
+
issues.push({
|
|
1717
|
+
path,
|
|
1718
|
+
keyword: "minLength",
|
|
1719
|
+
expected: `length >= ${minLength}`,
|
|
1720
|
+
received: `length ${value.length}`,
|
|
1721
|
+
message: `Parameter "${path}" must be at least ${minLength} characters, received ${value.length}.`
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
const maxLength = schema["maxLength"];
|
|
1725
|
+
if (typeof maxLength === "number" && value.length > maxLength) {
|
|
1726
|
+
issues.push({
|
|
1727
|
+
path,
|
|
1728
|
+
keyword: "maxLength",
|
|
1729
|
+
expected: `length <= ${maxLength}`,
|
|
1730
|
+
received: `length ${value.length}`,
|
|
1731
|
+
message: `Parameter "${path}" must be at most ${maxLength} characters, received ${value.length}.`
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
const pattern = schema["pattern"];
|
|
1735
|
+
if (typeof pattern === "string" && pattern.length <= MAX_PATTERN_LENGTH && !hasNestedQuantifiers(pattern)) {
|
|
1736
|
+
let re;
|
|
1737
|
+
try {
|
|
1738
|
+
re = new RegExp(pattern);
|
|
1739
|
+
} catch {
|
|
1740
|
+
re = null;
|
|
1741
|
+
}
|
|
1742
|
+
if (re !== null && !re.test(value)) {
|
|
1743
|
+
issues.push({
|
|
1744
|
+
path,
|
|
1745
|
+
keyword: "pattern",
|
|
1746
|
+
expected: `match /${pattern}/`,
|
|
1747
|
+
received: render(value),
|
|
1748
|
+
message: `Parameter "${path}" must match /${pattern}/, received ${render(value)}.`
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
if (Array.isArray(value) && hasOwn(schema, "items")) {
|
|
1754
|
+
const items = schema["items"];
|
|
1755
|
+
value.forEach((element, index) => {
|
|
1756
|
+
validateValue(element, items, `${path}[${index}]`, issues, depth + 1);
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
if (isPlainObject(value) && (isPlainObject(schema["properties"]) || hasOwn(schema, "required") || schema["additionalProperties"] === false)) {
|
|
1760
|
+
validateObject(value, schema, path, issues, depth);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
function validateObject(value, schema, basePath, issues, depth) {
|
|
1764
|
+
if (depth > MAX_SCHEMA_DEPTH) return;
|
|
1765
|
+
const properties = isPlainObject(schema["properties"]) ? schema["properties"] : {};
|
|
1766
|
+
const requiredRaw = schema["required"];
|
|
1767
|
+
const required = Array.isArray(requiredRaw) ? requiredRaw.filter((k) => typeof k === "string") : [];
|
|
1768
|
+
for (const key of required) {
|
|
1769
|
+
if (!hasOwn(value, key) || value[key] === void 0) {
|
|
1770
|
+
const at = joinPath(basePath, key);
|
|
1771
|
+
issues.push({
|
|
1772
|
+
path: at,
|
|
1773
|
+
keyword: "required",
|
|
1774
|
+
expected: "present",
|
|
1775
|
+
message: `Required parameter "${at}" is missing.`
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
if (schema["additionalProperties"] === false) {
|
|
1780
|
+
const declared = new Set(Object.keys(properties));
|
|
1781
|
+
const expected = declared.size === 0 ? "no parameters" : `one of [${[...declared].join(", ")}]`;
|
|
1782
|
+
for (const key of Object.keys(value)) {
|
|
1783
|
+
if (!declared.has(key)) {
|
|
1784
|
+
const at = joinPath(basePath, key);
|
|
1785
|
+
issues.push({
|
|
1786
|
+
path: at,
|
|
1787
|
+
keyword: "additionalProperties",
|
|
1788
|
+
expected,
|
|
1789
|
+
received: render(value[key]),
|
|
1790
|
+
message: `Parameter "${at}" is not declared by the schema, which sets additionalProperties: false.`
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
for (const [key, propSchema] of Object.entries(properties)) {
|
|
1796
|
+
if (!hasOwn(value, key) || value[key] === void 0) continue;
|
|
1797
|
+
validateValue(value[key], propSchema, joinPath(basePath, key), issues, depth + 1);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
function isObjectSchemaForm(schema) {
|
|
1801
|
+
if (isPlainObject(schema["properties"])) return true;
|
|
1802
|
+
if (schema["type"] !== "object") return false;
|
|
1803
|
+
return Object.keys(schema).every((key) => OBJECT_SCHEMA_KEYWORDS.has(key));
|
|
1804
|
+
}
|
|
1805
|
+
function normalizeMapForm(schema) {
|
|
1806
|
+
if (isObjectSchemaForm(schema)) return null;
|
|
1807
|
+
const properties = {};
|
|
1808
|
+
for (const [key, raw] of Object.entries(schema)) {
|
|
1809
|
+
if (raw === null || raw === void 0) continue;
|
|
1810
|
+
if (typeof raw === "string") {
|
|
1811
|
+
properties[key] = PRIMITIVE_TYPE_NAMES.has(raw) ? { type: raw } : {};
|
|
1812
|
+
continue;
|
|
1813
|
+
}
|
|
1814
|
+
if (isPlainObject(raw)) {
|
|
1815
|
+
const { optional: _optional, ...rest } = raw;
|
|
1816
|
+
properties[key] = rest;
|
|
1817
|
+
continue;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
return { type: "object", properties };
|
|
1821
|
+
}
|
|
1822
|
+
function validateActionParams(schema, params) {
|
|
1823
|
+
if (!isPlainObject(schema)) return { valid: true, issues: [] };
|
|
1824
|
+
const normalized = normalizeMapForm(schema) ?? schema;
|
|
1825
|
+
const hasProperties = isPlainObject(normalized["properties"]);
|
|
1826
|
+
const hasRequired = Array.isArray(normalized["required"]);
|
|
1827
|
+
const forbidsExtras = normalized["additionalProperties"] === false;
|
|
1828
|
+
if (!hasProperties && !hasRequired && !forbidsExtras) {
|
|
1829
|
+
return { valid: true, issues: [] };
|
|
1830
|
+
}
|
|
1831
|
+
const issues = [];
|
|
1832
|
+
if (params === void 0 || params === null) {
|
|
1833
|
+
validateObject({}, normalized, "", issues, 0);
|
|
1834
|
+
return { valid: issues.length === 0, issues };
|
|
1835
|
+
}
|
|
1836
|
+
if (!isPlainObject(params)) {
|
|
1837
|
+
issues.push({
|
|
1838
|
+
path: "",
|
|
1839
|
+
keyword: "type",
|
|
1840
|
+
expected: "object",
|
|
1841
|
+
received: render(params),
|
|
1842
|
+
message: `Action params must be an object, received ${jsonTypeOf(params)} (${render(params)}).`
|
|
1843
|
+
});
|
|
1844
|
+
return { valid: false, issues };
|
|
1845
|
+
}
|
|
1846
|
+
validateObject(params, normalized, "", issues, 0);
|
|
1847
|
+
return { valid: issues.length === 0, issues };
|
|
1848
|
+
}
|
|
1849
|
+
function formatParamValidationFailure(componentId, actionId, issues) {
|
|
1850
|
+
const detail = issues.map((i) => i.message).join(" ");
|
|
1851
|
+
return `Action "${actionId}" on component "${componentId}" was rejected: params do not match the action's declared paramSchema. ${detail}`;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1163
1854
|
// src/control/action-executor.ts
|
|
1164
1855
|
var DEFAULT_WAIT_OPTIONS = {
|
|
1165
1856
|
visible: true,
|
|
@@ -1169,6 +1860,68 @@ var DEFAULT_WAIT_OPTIONS = {
|
|
|
1169
1860
|
timeout: 1e4,
|
|
1170
1861
|
interval: 100
|
|
1171
1862
|
};
|
|
1863
|
+
var NativeActionError = class extends Error {
|
|
1864
|
+
constructor(message, code) {
|
|
1865
|
+
super(message);
|
|
1866
|
+
this.code = code;
|
|
1867
|
+
this.name = "NativeActionError";
|
|
1868
|
+
}
|
|
1869
|
+
};
|
|
1870
|
+
var DEFAULT_SCROLL_INTO_VIEW_PADDING = 16;
|
|
1871
|
+
var SCROLL_INTO_VIEW_MEASURE_TIMEOUT_MS = 1e3;
|
|
1872
|
+
var SCROLL_INTO_VIEW_WINDOW_MEASURE_TIMEOUT_MS = 250;
|
|
1873
|
+
function measurePageRectNow(element, preferred) {
|
|
1874
|
+
const preferredNode = preferred;
|
|
1875
|
+
const node = preferredNode && typeof preferredNode.measureInWindow === "function" ? preferredNode : element.ref.current;
|
|
1876
|
+
if (!node || typeof node.measureInWindow !== "function") {
|
|
1877
|
+
const state = element.getState();
|
|
1878
|
+
return Promise.resolve(state.visible ? pageRectOf(state) : null);
|
|
1879
|
+
}
|
|
1880
|
+
return new Promise((resolve) => {
|
|
1881
|
+
let settled = false;
|
|
1882
|
+
const done = (rect) => {
|
|
1883
|
+
if (settled) return;
|
|
1884
|
+
settled = true;
|
|
1885
|
+
clearTimeout(timer);
|
|
1886
|
+
resolve(rect);
|
|
1887
|
+
};
|
|
1888
|
+
const timer = setTimeout(() => done(null), SCROLL_INTO_VIEW_WINDOW_MEASURE_TIMEOUT_MS);
|
|
1889
|
+
try {
|
|
1890
|
+
node.measureInWindow(
|
|
1891
|
+
(x, y, w, h) => done(
|
|
1892
|
+
[x, y, w, h].every(Number.isFinite) && w > 0 && h > 0 ? { left: x, top: y, right: x + w, bottom: y + h } : null
|
|
1893
|
+
)
|
|
1894
|
+
);
|
|
1895
|
+
} catch {
|
|
1896
|
+
done(null);
|
|
1897
|
+
}
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
function resolveScrollView(current) {
|
|
1901
|
+
const c = current;
|
|
1902
|
+
if (!c) return null;
|
|
1903
|
+
if (typeof c.scrollTo === "function")
|
|
1904
|
+
return c;
|
|
1905
|
+
for (const unwrap of [c.getNativeScrollRef, c.getScrollResponder]) {
|
|
1906
|
+
if (typeof unwrap !== "function") continue;
|
|
1907
|
+
let inner;
|
|
1908
|
+
try {
|
|
1909
|
+
inner = unwrap.call(c);
|
|
1910
|
+
} catch {
|
|
1911
|
+
inner = null;
|
|
1912
|
+
}
|
|
1913
|
+
if (inner && typeof inner.scrollTo === "function")
|
|
1914
|
+
return inner;
|
|
1915
|
+
}
|
|
1916
|
+
return null;
|
|
1917
|
+
}
|
|
1918
|
+
function isDeclaredHorizontal(registeredProps, ...refs) {
|
|
1919
|
+
if (registeredProps?.horizontal === true) return true;
|
|
1920
|
+
return refs.some((r) => {
|
|
1921
|
+
const props = r?.props;
|
|
1922
|
+
return props !== null && typeof props === "object" && props.horizontal === true;
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1172
1925
|
function sleep(ms) {
|
|
1173
1926
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1174
1927
|
}
|
|
@@ -1288,6 +2041,7 @@ var DefaultNativeActionExecutor = class {
|
|
|
1288
2041
|
return {
|
|
1289
2042
|
success: false,
|
|
1290
2043
|
error: errorMsg,
|
|
2044
|
+
...error3 instanceof NativeActionError ? { code: error3.code } : {},
|
|
1291
2045
|
stack: error3 instanceof Error ? error3.stack : void 0,
|
|
1292
2046
|
durationMs: failTime - startTime,
|
|
1293
2047
|
timestamp: failTime,
|
|
@@ -1305,7 +2059,7 @@ var DefaultNativeActionExecutor = class {
|
|
|
1305
2059
|
}
|
|
1306
2060
|
const props = element.props || {};
|
|
1307
2061
|
if (element.customActions && action in element.customActions) {
|
|
1308
|
-
return element.customActions[action].handler(params);
|
|
2062
|
+
return element.customActions[action].handler(params, { signal: inertAbortSignal() });
|
|
1309
2063
|
}
|
|
1310
2064
|
switch (action) {
|
|
1311
2065
|
case "click":
|
|
@@ -1328,6 +2082,11 @@ var DefaultNativeActionExecutor = class {
|
|
|
1328
2082
|
return this.performBlur(element);
|
|
1329
2083
|
case "scroll":
|
|
1330
2084
|
return this.performScroll(props, params);
|
|
2085
|
+
case "scrollIntoView":
|
|
2086
|
+
return this.performScrollIntoView(
|
|
2087
|
+
element,
|
|
2088
|
+
params
|
|
2089
|
+
);
|
|
1331
2090
|
case "swipe":
|
|
1332
2091
|
return this.performSwipe(props, params);
|
|
1333
2092
|
case "toggle":
|
|
@@ -1494,6 +2253,138 @@ var DefaultNativeActionExecutor = class {
|
|
|
1494
2253
|
props.onScroll(event);
|
|
1495
2254
|
}
|
|
1496
2255
|
}
|
|
2256
|
+
/**
|
|
2257
|
+
* Perform scrollIntoView: scroll the element's DECLARED scroll container so
|
|
2258
|
+
* the element sits `padding` dp below the top of its viewport.
|
|
2259
|
+
*
|
|
2260
|
+
* The container comes from `scrollAncestorId` and nowhere else — the
|
|
2261
|
+
* registry keeps no parent chain (`RegisterElementOptions.scrollAncestorId`),
|
|
2262
|
+
* so there is no "nearest ScrollView" to find. Nothing here tracks a
|
|
2263
|
+
* ScrollView's content offset either (`performScroll` only fakes an
|
|
2264
|
+
* `onScroll` event), so the target is measured CONTENT-relative with
|
|
2265
|
+
* `measureLayout` against the container's inner content view: stateless,
|
|
2266
|
+
* and immune to a stale-offset race.
|
|
2267
|
+
*
|
|
2268
|
+
* Result shape mirrors the web executor's `scrollIntoView`
|
|
2269
|
+
* (`@qontinui/ui-bridge` `control/action-executor.ts`):
|
|
2270
|
+
* `{ alreadyVisible, scrolled }`, short-circuiting when the element is
|
|
2271
|
+
* already fully inside its clip region. "Fully visible" is only claimed when
|
|
2272
|
+
* it is MEASURED — a page-space rect for the element and a known,
|
|
2273
|
+
* non-empty clip (window ∩ declared container), both measured at call
|
|
2274
|
+
* time. Anything unknown scrolls.
|
|
2275
|
+
*
|
|
2276
|
+
* Containers: a ScrollView, or a VirtualizedList (FlatList / SectionList)
|
|
2277
|
+
* unwrapped through `getNativeScrollRef()` / `getScrollResponder()`.
|
|
2278
|
+
* Vertical only — a `horizontal` container is NOT_SUPPORTED. The action
|
|
2279
|
+
* returns once `scrollTo` is dispatched; positions settle on the next frame.
|
|
2280
|
+
*
|
|
2281
|
+
* Prerequisites are checked BEFORE the visibility short-circuit, so whether
|
|
2282
|
+
* the element supports the action does not depend on where it happens to be
|
|
2283
|
+
* on screen. Each missing prerequisite is a `NOT_SUPPORTED` failure.
|
|
2284
|
+
*/
|
|
2285
|
+
async performScrollIntoView(element, params) {
|
|
2286
|
+
const padding = params?.padding ?? DEFAULT_SCROLL_INTO_VIEW_PADDING;
|
|
2287
|
+
if (typeof padding !== "number" || !Number.isFinite(padding) || padding < 0) {
|
|
2288
|
+
throw new Error('scrollIntoView "padding" must be a finite, non-negative number');
|
|
2289
|
+
}
|
|
2290
|
+
const ancestorId = element.scrollAncestorId;
|
|
2291
|
+
if (!ancestorId) {
|
|
2292
|
+
throw new NativeActionError(
|
|
2293
|
+
`scrollIntoView is not supported on element "${element.id}": it declares no scrollAncestorId, and the registry keeps no parent chain to find its scroll container from`,
|
|
2294
|
+
"NOT_SUPPORTED"
|
|
2295
|
+
);
|
|
2296
|
+
}
|
|
2297
|
+
const ancestor = ancestorId === element.id ? void 0 : this.registry.getElement(ancestorId);
|
|
2298
|
+
if (!ancestor) {
|
|
2299
|
+
throw new NativeActionError(
|
|
2300
|
+
`scrollIntoView is not supported on element "${element.id}": its declared scrollAncestorId "${ancestorId}" is not a registered element`,
|
|
2301
|
+
"NOT_SUPPORTED"
|
|
2302
|
+
);
|
|
2303
|
+
}
|
|
2304
|
+
const container = resolveScrollView(ancestor.ref.current);
|
|
2305
|
+
if (!container) {
|
|
2306
|
+
throw new NativeActionError(
|
|
2307
|
+
`scrollIntoView is not supported on element "${element.id}": its scroll ancestor "${ancestorId}" has no scrollTo (not a mounted ScrollView, and no getNativeScrollRef/getScrollResponder yielding one)`,
|
|
2308
|
+
"NOT_SUPPORTED"
|
|
2309
|
+
);
|
|
2310
|
+
}
|
|
2311
|
+
if (isDeclaredHorizontal(ancestor.props, ancestor.ref.current, container)) {
|
|
2312
|
+
throw new NativeActionError(
|
|
2313
|
+
`scrollIntoView is not supported on element "${element.id}": its scroll ancestor "${ancestorId}" is horizontal (only vertical containers are supported)`,
|
|
2314
|
+
"NOT_SUPPORTED"
|
|
2315
|
+
);
|
|
2316
|
+
}
|
|
2317
|
+
const innerViewRef = container.getInnerViewRef?.();
|
|
2318
|
+
const innerView = innerViewRef ?? container.getInnerViewNode?.();
|
|
2319
|
+
const innerViewIsTag = innerViewRef == null && typeof innerView === "number";
|
|
2320
|
+
if (innerView === void 0 || innerView === null) {
|
|
2321
|
+
throw new NativeActionError(
|
|
2322
|
+
`scrollIntoView is not supported on element "${element.id}": its scroll ancestor "${ancestorId}" exposes no inner content view to measure against`,
|
|
2323
|
+
"NOT_SUPPORTED"
|
|
2324
|
+
);
|
|
2325
|
+
}
|
|
2326
|
+
const target = element.ref.current;
|
|
2327
|
+
if (!target || typeof target.measureLayout !== "function") {
|
|
2328
|
+
throw new NativeActionError(
|
|
2329
|
+
`scrollIntoView is not supported on element "${element.id}": its ref has no measureLayout (not a mounted host view)`,
|
|
2330
|
+
"NOT_SUPPORTED"
|
|
2331
|
+
);
|
|
2332
|
+
}
|
|
2333
|
+
const [rect, ancestorRect] = await Promise.all([
|
|
2334
|
+
measurePageRectNow(element),
|
|
2335
|
+
measurePageRectNow(ancestor, container)
|
|
2336
|
+
]);
|
|
2337
|
+
const viewport = this.registry.getViewportRect();
|
|
2338
|
+
const clip = ancestorRect ? intersectRects(viewport, ancestorRect) : null;
|
|
2339
|
+
if (rect && clip && !isEmptyRect(clip) && rect.right > rect.left && rect.bottom > rect.top && rect.top >= clip.top && rect.left >= clip.left && rect.bottom <= clip.bottom && rect.right <= clip.right) {
|
|
2340
|
+
return { alreadyVisible: true, scrolled: false };
|
|
2341
|
+
}
|
|
2342
|
+
const measured = await new Promise(
|
|
2343
|
+
(resolve, reject) => {
|
|
2344
|
+
let settled = false;
|
|
2345
|
+
const timer = setTimeout(() => {
|
|
2346
|
+
if (settled) return;
|
|
2347
|
+
settled = true;
|
|
2348
|
+
reject(
|
|
2349
|
+
new Error(
|
|
2350
|
+
`scrollIntoView: measureLayout did not call back within ${SCROLL_INTO_VIEW_MEASURE_TIMEOUT_MS}ms` + (innerViewIsTag ? " (measured against a numeric inner-view tag from getInnerViewNode, which the new architecture does not support)" : "")
|
|
2351
|
+
)
|
|
2352
|
+
);
|
|
2353
|
+
}, SCROLL_INTO_VIEW_MEASURE_TIMEOUT_MS);
|
|
2354
|
+
const finish = (fn) => {
|
|
2355
|
+
if (settled) return;
|
|
2356
|
+
settled = true;
|
|
2357
|
+
clearTimeout(timer);
|
|
2358
|
+
fn();
|
|
2359
|
+
};
|
|
2360
|
+
try {
|
|
2361
|
+
target.measureLayout(
|
|
2362
|
+
innerView,
|
|
2363
|
+
(x, y, width) => finish(
|
|
2364
|
+
() => Number.isFinite(y) ? resolve({ x, y, width }) : reject(new Error("scrollIntoView: measureLayout returned a non-finite y"))
|
|
2365
|
+
),
|
|
2366
|
+
() => finish(
|
|
2367
|
+
() => reject(
|
|
2368
|
+
new Error(
|
|
2369
|
+
`scrollIntoView: measureLayout failed for "${element.id}" relative to "${ancestorId}"`
|
|
2370
|
+
)
|
|
2371
|
+
)
|
|
2372
|
+
)
|
|
2373
|
+
);
|
|
2374
|
+
} catch (error3) {
|
|
2375
|
+
finish(() => reject(error3));
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
);
|
|
2379
|
+
if (ancestorRect && Number.isFinite(measured.x) && Number.isFinite(measured.width) && measured.x + measured.width > ancestorRect.right - ancestorRect.left + 1) {
|
|
2380
|
+
throw new NativeActionError(
|
|
2381
|
+
`scrollIntoView is not supported on element "${element.id}": it is laid out beyond the horizontal extent of its scroll ancestor "${ancestorId}" (sideways overflow, a horizontal container, or a nested horizontal container \u2014 declare that inner container as its scrollAncestorId); only vertical scrolling is supported`,
|
|
2382
|
+
"NOT_SUPPORTED"
|
|
2383
|
+
);
|
|
2384
|
+
}
|
|
2385
|
+
container.scrollTo({ y: Math.max(0, measured.y - padding), animated: false });
|
|
2386
|
+
return { alreadyVisible: false, scrolled: true };
|
|
2387
|
+
}
|
|
1497
2388
|
/**
|
|
1498
2389
|
* Perform swipe action
|
|
1499
2390
|
*/
|
|
@@ -1548,7 +2439,7 @@ var DefaultNativeActionExecutor = class {
|
|
|
1548
2439
|
/**
|
|
1549
2440
|
* Execute a component action
|
|
1550
2441
|
*/
|
|
1551
|
-
async executeComponentAction(componentId, request) {
|
|
2442
|
+
async executeComponentAction(componentId, request, options = {}) {
|
|
1552
2443
|
const startTime = Date.now();
|
|
1553
2444
|
try {
|
|
1554
2445
|
const component = this.registry.getComponent(componentId);
|
|
@@ -1571,10 +2462,71 @@ var DefaultNativeActionExecutor = class {
|
|
|
1571
2462
|
requestId: request.requestId
|
|
1572
2463
|
};
|
|
1573
2464
|
}
|
|
1574
|
-
const
|
|
2465
|
+
const timeout = normalizeActionTimeoutMs(request.timeoutMs);
|
|
2466
|
+
if (!timeout.ok) {
|
|
2467
|
+
return {
|
|
2468
|
+
success: false,
|
|
2469
|
+
error: `Action "${request.action}" on component "${componentId}" was rejected: ${timeout.reason}.`,
|
|
2470
|
+
durationMs: Date.now() - startTime,
|
|
2471
|
+
timestamp: Date.now(),
|
|
2472
|
+
requestId: request.requestId
|
|
2473
|
+
};
|
|
2474
|
+
}
|
|
2475
|
+
const timeoutMs = timeout.timeoutMs;
|
|
2476
|
+
const validationMode = options.paramValidation ?? getDefaultParamValidationMode();
|
|
2477
|
+
if (validationMode !== "off" && action.paramSchema !== void 0) {
|
|
2478
|
+
let validation;
|
|
2479
|
+
try {
|
|
2480
|
+
validation = validateActionParams(action.paramSchema, request.params);
|
|
2481
|
+
} catch (validatorFault) {
|
|
2482
|
+
const detail = validatorFault instanceof Error ? validatorFault.message : String(validatorFault);
|
|
2483
|
+
const faultMessage = `Action "${request.action}" on component "${componentId}": its declared paramSchema could not be evaluated (${detail}).`;
|
|
2484
|
+
if (validationMode === "enforce") {
|
|
2485
|
+
return {
|
|
2486
|
+
success: false,
|
|
2487
|
+
error: faultMessage,
|
|
2488
|
+
durationMs: Date.now() - startTime,
|
|
2489
|
+
timestamp: Date.now(),
|
|
2490
|
+
requestId: request.requestId
|
|
2491
|
+
};
|
|
2492
|
+
}
|
|
2493
|
+
console.warn(`[ui-bridge-native] ${faultMessage}`);
|
|
2494
|
+
validation = { valid: true, issues: [] };
|
|
2495
|
+
}
|
|
2496
|
+
if (!validation.valid) {
|
|
2497
|
+
const message = formatParamValidationFailure(
|
|
2498
|
+
componentId,
|
|
2499
|
+
request.action,
|
|
2500
|
+
validation.issues
|
|
2501
|
+
);
|
|
2502
|
+
if (validationMode === "enforce") {
|
|
2503
|
+
return {
|
|
2504
|
+
success: false,
|
|
2505
|
+
error: message,
|
|
2506
|
+
durationMs: Date.now() - startTime,
|
|
2507
|
+
timestamp: Date.now(),
|
|
2508
|
+
requestId: request.requestId
|
|
2509
|
+
};
|
|
2510
|
+
}
|
|
2511
|
+
console.warn(`[ui-bridge-native] ${message}`);
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
const outcome = await runAbortable((signal) => action.handler(request.params, { signal }), {
|
|
2515
|
+
signal: options.signal,
|
|
2516
|
+
timeoutMs
|
|
2517
|
+
});
|
|
2518
|
+
if (outcome.aborted) {
|
|
2519
|
+
return {
|
|
2520
|
+
success: false,
|
|
2521
|
+
error: outcome.reason === "timeout" ? `Action "${request.action}" on component "${componentId}" was abandoned after its ${timeoutMs}ms timeout elapsed.` : `Action "${request.action}" on component "${componentId}" was cancelled by the caller's abort signal.`,
|
|
2522
|
+
durationMs: Date.now() - startTime,
|
|
2523
|
+
timestamp: Date.now(),
|
|
2524
|
+
requestId: request.requestId
|
|
2525
|
+
};
|
|
2526
|
+
}
|
|
1575
2527
|
return {
|
|
1576
2528
|
success: true,
|
|
1577
|
-
result,
|
|
2529
|
+
result: outcome.result,
|
|
1578
2530
|
durationMs: Date.now() - startTime,
|
|
1579
2531
|
timestamp: Date.now(),
|
|
1580
2532
|
requestId: request.requestId
|
|
@@ -2648,10 +3600,14 @@ function createServerHandlers(registry, executor, config) {
|
|
|
2648
3600
|
if (forRoute) {
|
|
2649
3601
|
allElements = allElements.filter((e) => matchesCurrentRoute(e.registrationRoute, forRoute));
|
|
2650
3602
|
}
|
|
3603
|
+
const viewport = registry.getViewportRect();
|
|
2651
3604
|
const elements = allElements.map((e) => {
|
|
2652
3605
|
const handlers = extractHandlerNames(e.props);
|
|
2653
3606
|
const state = e.getState();
|
|
2654
|
-
const visibility
|
|
3607
|
+
const { visibility, visibilityReason } = computeVisibility(
|
|
3608
|
+
state,
|
|
3609
|
+
registry.getClipRectFor(e, viewport)
|
|
3610
|
+
);
|
|
2655
3611
|
const bbox = projectBbox(state, visibility, registry.getPixelRatio());
|
|
2656
3612
|
const vision = projectVisionFields({
|
|
2657
3613
|
type: e.type,
|
|
@@ -2669,10 +3625,11 @@ function createServerHandlers(registry, executor, config) {
|
|
|
2669
3625
|
identifier: e.getIdentifier(),
|
|
2670
3626
|
state,
|
|
2671
3627
|
actions: e.actions,
|
|
2672
|
-
customActions:
|
|
3628
|
+
customActions: serializeElementCustomActions(e.customActions),
|
|
2673
3629
|
registeredHandlers: handlers.length > 0 ? handlers : void 0,
|
|
2674
3630
|
registrationRoute: e.registrationRoute,
|
|
2675
3631
|
visibility,
|
|
3632
|
+
...visibilityReason ? { visibilityReason } : {},
|
|
2676
3633
|
...bbox ? { bbox } : {},
|
|
2677
3634
|
...vision
|
|
2678
3635
|
};
|
|
@@ -2694,7 +3651,7 @@ function createServerHandlers(registry, executor, config) {
|
|
|
2694
3651
|
identifier: element.getIdentifier(),
|
|
2695
3652
|
state: resolveElementState(element),
|
|
2696
3653
|
actions: element.actions,
|
|
2697
|
-
customActions:
|
|
3654
|
+
customActions: serializeElementCustomActions(element.customActions),
|
|
2698
3655
|
registeredHandlers: handlers.length > 0 ? handlers : void 0,
|
|
2699
3656
|
registrationRoute: element.registrationRoute
|
|
2700
3657
|
}
|
|
@@ -2715,13 +3672,9 @@ function createServerHandlers(registry, executor, config) {
|
|
|
2715
3672
|
if (!envelope?.action) {
|
|
2716
3673
|
return error2("Action is required", "INVALID_REQUEST");
|
|
2717
3674
|
}
|
|
2718
|
-
const response = await executor.executeAction(id,
|
|
2719
|
-
action: envelope.action,
|
|
2720
|
-
params: envelope.params,
|
|
2721
|
-
waitOptions: envelope.waitOptions
|
|
2722
|
-
});
|
|
3675
|
+
const response = await executor.executeAction(id, envelope);
|
|
2723
3676
|
if (!response.success) {
|
|
2724
|
-
return error2(response.error || "Action failed", "ACTION_FAILED");
|
|
3677
|
+
return error2(response.error || "Action failed", response.code ?? "ACTION_FAILED");
|
|
2725
3678
|
}
|
|
2726
3679
|
return success2(response);
|
|
2727
3680
|
},
|
|
@@ -2731,7 +3684,11 @@ function createServerHandlers(registry, executor, config) {
|
|
|
2731
3684
|
id: c.id,
|
|
2732
3685
|
name: c.name,
|
|
2733
3686
|
description: c.description,
|
|
2734
|
-
|
|
3687
|
+
// Phase 4 (plan 2026-08-20-ui-bridge-action-declaration-shape):
|
|
3688
|
+
// carry the safety annotation onto the native wire too — a walker
|
|
3689
|
+
// driving a React Native app needs the same `destructive` exclusion
|
|
3690
|
+
// the web channel gets.
|
|
3691
|
+
actions: c.actions.map((a) => ({ id: a.id, label: a.label, effect: a.effect })),
|
|
2735
3692
|
elementIds: c.elementIds
|
|
2736
3693
|
}));
|
|
2737
3694
|
return success2({ components });
|
|
@@ -2750,7 +3707,9 @@ function createServerHandlers(registry, executor, config) {
|
|
|
2750
3707
|
actions: component.actions.map((a) => ({
|
|
2751
3708
|
id: a.id,
|
|
2752
3709
|
label: a.label,
|
|
2753
|
-
description: a.description
|
|
3710
|
+
description: a.description,
|
|
3711
|
+
// Phase 4 — see the listing twin above.
|
|
3712
|
+
effect: a.effect
|
|
2754
3713
|
})),
|
|
2755
3714
|
elementIds: component.elementIds
|
|
2756
3715
|
}
|
|
@@ -2761,7 +3720,8 @@ function createServerHandlers(registry, executor, config) {
|
|
|
2761
3720
|
const body = ctx.body;
|
|
2762
3721
|
const response = await executor.executeComponentAction(id, {
|
|
2763
3722
|
action: actionId,
|
|
2764
|
-
params: body?.params
|
|
3723
|
+
params: body?.params,
|
|
3724
|
+
timeoutMs: body?.timeoutMs
|
|
2765
3725
|
});
|
|
2766
3726
|
if (!response.success) {
|
|
2767
3727
|
return error2(response.error || "Action failed", "ACTION_FAILED");
|
|
@@ -2953,9 +3913,12 @@ function createServerHandlers(registry, executor, config) {
|
|
|
2953
3913
|
//
|
|
2954
3914
|
// The mobile bridge mounts explicit NOT_SUPPORTED stubs at the runner's
|
|
2955
3915
|
// cheatsheet paths so callers (and the /manual-test skill in particular)
|
|
2956
|
-
// get a structured envelope explaining the gap rather than a
|
|
2957
|
-
//
|
|
2958
|
-
//
|
|
3916
|
+
// get a structured envelope explaining the gap rather than a bare
|
|
3917
|
+
// "no such route". These answer **HTTP 501** — `httpStatusForResponse` in
|
|
3918
|
+
// server/http-server.ts maps NOT_SUPPORTED → 501, so the status line and
|
|
3919
|
+
// the envelope agree; an unmounted path answers 404 instead. Each error
|
|
3920
|
+
// message names an in-tree mobile replacement so operators aren't left
|
|
3921
|
+
// guessing what to use instead.
|
|
2959
3922
|
aiForms: async () => {
|
|
2960
3923
|
return error2(
|
|
2961
3924
|
'ai/forms (form discovery) is runner-only; mobile React Native has no DOM to walk. Use GET /ui-bridge/control/snapshot and filter for type === "input" instead.',
|
|
@@ -3282,18 +4245,12 @@ function createServerHandlers(registry, executor, config) {
|
|
|
3282
4245
|
pushModal: async (ctx) => {
|
|
3283
4246
|
const detector = registry.getEnrichers().modalDetector;
|
|
3284
4247
|
if (!detector) {
|
|
3285
|
-
return error2(
|
|
3286
|
-
"modal detector not registered",
|
|
3287
|
-
"ENRICHER_UNAVAILABLE"
|
|
3288
|
-
);
|
|
4248
|
+
return error2("modal detector not registered", "ENRICHER_UNAVAILABLE");
|
|
3289
4249
|
}
|
|
3290
4250
|
const body = ctx.body;
|
|
3291
4251
|
const id = body?.id;
|
|
3292
4252
|
if (typeof id !== "string" || id.length === 0) {
|
|
3293
|
-
return error2(
|
|
3294
|
-
'pushModal requires a non-empty "id"',
|
|
3295
|
-
"INVALID_REQUEST"
|
|
3296
|
-
);
|
|
4253
|
+
return error2('pushModal requires a non-empty "id"', "INVALID_REQUEST");
|
|
3297
4254
|
}
|
|
3298
4255
|
const allowedTypes = ["modal", "sheet", "drawer", "popover", "alertdialog", "dialog"];
|
|
3299
4256
|
const requestedType = body?.type;
|
|
@@ -3326,10 +4283,7 @@ function createServerHandlers(registry, executor, config) {
|
|
|
3326
4283
|
dismissModal: async (ctx) => {
|
|
3327
4284
|
const detector = registry.getEnrichers().modalDetector;
|
|
3328
4285
|
if (!detector) {
|
|
3329
|
-
return error2(
|
|
3330
|
-
"modal detector not registered",
|
|
3331
|
-
"ENRICHER_UNAVAILABLE"
|
|
3332
|
-
);
|
|
4286
|
+
return error2("modal detector not registered", "ENRICHER_UNAVAILABLE");
|
|
3333
4287
|
}
|
|
3334
4288
|
const { id } = ctx.params;
|
|
3335
4289
|
if (typeof id !== "string" || id.length === 0) {
|
|
@@ -3359,7 +4313,7 @@ function createServerHandlers(registry, executor, config) {
|
|
|
3359
4313
|
};
|
|
3360
4314
|
if (config?.appInfo) {
|
|
3361
4315
|
response.uiBridge = {
|
|
3362
|
-
version: "0.6.
|
|
4316
|
+
version: "0.6.14",
|
|
3363
4317
|
...config.appInfo,
|
|
3364
4318
|
capabilities: ["elements", "components", "actions", "design"],
|
|
3365
4319
|
elementCount: stats.elements,
|
|
@@ -3507,8 +4461,48 @@ function extractMethod(input, init) {
|
|
|
3507
4461
|
var NetworkRequestBuffer = class {
|
|
3508
4462
|
constructor(capacity = 100) {
|
|
3509
4463
|
this.installed = false;
|
|
4464
|
+
/**
|
|
4465
|
+
* Synchronous re-entrancy depth: non-zero while the ORIGINAL `fetch` is
|
|
4466
|
+
* executing, which is exactly the window in which React Native's whatwg-fetch
|
|
4467
|
+
* polyfill constructs and opens its internal `XMLHttpRequest`.
|
|
4468
|
+
*
|
|
4469
|
+
* A counter rather than a boolean so a `fetch` called from inside a `fetch`
|
|
4470
|
+
* (an interceptor, a retry wrapper) still unwinds to zero. Safe as a plain
|
|
4471
|
+
* number: JS is single-threaded and the only writes are the paired
|
|
4472
|
+
* increment/decrement around one synchronous call.
|
|
4473
|
+
*/
|
|
4474
|
+
this.fetchDepth = { value: 0 };
|
|
3510
4475
|
this.buffer = new RingBuffer(capacity);
|
|
3511
4476
|
}
|
|
4477
|
+
/**
|
|
4478
|
+
* Patch `fetch` and `XMLHttpRequest` so `GET /sdk/network-requests` can
|
|
4479
|
+
* report what the app talked to.
|
|
4480
|
+
*
|
|
4481
|
+
* ONE REQUEST MUST PRODUCE ONE ENTRY. React Native implements `fetch` **on
|
|
4482
|
+
* top of** XHR (whatwg-fetch), so both patches observe the same request and
|
|
4483
|
+
* the buffer used to carry every call twice — same url, timestamps ~1ms
|
|
4484
|
+
* apart, and on failure one `"Network request failed"` beside one
|
|
4485
|
+
* `"XMLHttpRequest error"`. A tester reads that as a double-fetch bug in the
|
|
4486
|
+
* app and chases a defect that does not exist.
|
|
4487
|
+
*
|
|
4488
|
+
* The de-dup: `fetchDepth` is raised across the synchronous call into the
|
|
4489
|
+
* original `fetch`, which is when the polyfill builds its XHR; `open` stamps
|
|
4490
|
+
* any XHR created in that window with `__uiBridgeFromFetch`, and `send`
|
|
4491
|
+
* skips recording those. The `fetch` patch keeps the entry, because it is
|
|
4492
|
+
* the layer the app actually called and the only one that sees `Response.ok`.
|
|
4493
|
+
*
|
|
4494
|
+
* Uninstalling both patches together keeps this sound: an XHR can only be
|
|
4495
|
+
* marked while the fetch patch is live.
|
|
4496
|
+
*
|
|
4497
|
+
* KNOWN LIMIT — it is coupled to the polyfill building its XHR SYNCHRONOUSLY,
|
|
4498
|
+
* which whatwg-fetch does (its work happens in the Promise executor). If the
|
|
4499
|
+
* host's `fetch` is wrapped by an interceptor that defers the real call to a
|
|
4500
|
+
* microtask or `setTimeout`, the depth is back to 0 by the time `open` runs
|
|
4501
|
+
* and every request duplicates again — the original symptom, with no signal
|
|
4502
|
+
* that the mechanism disengaged. Duplicates in `/sdk/network-requests` are
|
|
4503
|
+
* the thing to look for; they mean this assumption stopped holding, not that
|
|
4504
|
+
* the app double-fetched.
|
|
4505
|
+
*/
|
|
3512
4506
|
install() {
|
|
3513
4507
|
if (this.installed) return;
|
|
3514
4508
|
const g = globalThis;
|
|
@@ -3516,12 +4510,20 @@ var NetworkRequestBuffer = class {
|
|
|
3516
4510
|
this.originalFetch = g.fetch;
|
|
3517
4511
|
const buffer = this.buffer;
|
|
3518
4512
|
const original = this.originalFetch;
|
|
4513
|
+
const fetchDepth = this.fetchDepth;
|
|
3519
4514
|
const patched = async (input, init) => {
|
|
3520
4515
|
const start = Date.now();
|
|
3521
4516
|
const url = extractUrl(input);
|
|
3522
4517
|
const method = extractMethod(input, init);
|
|
3523
4518
|
try {
|
|
3524
|
-
|
|
4519
|
+
let pending;
|
|
4520
|
+
fetchDepth.value += 1;
|
|
4521
|
+
try {
|
|
4522
|
+
pending = original.call(globalThis, input, init);
|
|
4523
|
+
} finally {
|
|
4524
|
+
fetchDepth.value -= 1;
|
|
4525
|
+
}
|
|
4526
|
+
const res = await pending;
|
|
3525
4527
|
buffer.push({
|
|
3526
4528
|
timestamp: start,
|
|
3527
4529
|
method,
|
|
@@ -3551,10 +4553,14 @@ var NetworkRequestBuffer = class {
|
|
|
3551
4553
|
this.originalXhrOpen = Xhr.prototype.open;
|
|
3552
4554
|
this.originalXhrSend = Xhr.prototype.send;
|
|
3553
4555
|
const buffer = this.buffer;
|
|
4556
|
+
const fetchDepth = this.fetchDepth;
|
|
3554
4557
|
const origOpen = this.originalXhrOpen;
|
|
3555
4558
|
Xhr.prototype.open = function(method, url, ...rest) {
|
|
3556
4559
|
this.__uiBridgeMethod = method.toUpperCase();
|
|
3557
4560
|
this.__uiBridgeUrl = typeof url === "string" ? url : String(url);
|
|
4561
|
+
if (fetchDepth.value > 0) {
|
|
4562
|
+
this.__uiBridgeFromFetch = true;
|
|
4563
|
+
}
|
|
3558
4564
|
return origOpen.call(
|
|
3559
4565
|
this,
|
|
3560
4566
|
method,
|
|
@@ -3564,6 +4570,9 @@ var NetworkRequestBuffer = class {
|
|
|
3564
4570
|
};
|
|
3565
4571
|
const origSend = this.originalXhrSend;
|
|
3566
4572
|
Xhr.prototype.send = function(body) {
|
|
4573
|
+
if (this.__uiBridgeFromFetch === true) {
|
|
4574
|
+
return origSend.call(this, body);
|
|
4575
|
+
}
|
|
3567
4576
|
const start = Date.now();
|
|
3568
4577
|
const method = this.__uiBridgeMethod ?? "GET";
|
|
3569
4578
|
const url = this.__uiBridgeUrl ?? "";
|
|
@@ -3643,6 +4652,16 @@ function isUnsubscribe(msg) {
|
|
|
3643
4652
|
}
|
|
3644
4653
|
|
|
3645
4654
|
// src/server/http-server.ts
|
|
4655
|
+
var ERROR_CODE_HTTP_STATUS = /* @__PURE__ */ new Map([
|
|
4656
|
+
["NOT_SUPPORTED", 501],
|
|
4657
|
+
["NOT_FOUND", 404],
|
|
4658
|
+
["METHOD_NOT_ALLOWED", 405]
|
|
4659
|
+
]);
|
|
4660
|
+
function httpStatusForResponse(response) {
|
|
4661
|
+
if (response.success) return 200;
|
|
4662
|
+
const mapped = response.code ? ERROR_CODE_HTTP_STATUS.get(response.code) : void 0;
|
|
4663
|
+
return mapped ?? 400;
|
|
4664
|
+
}
|
|
3646
4665
|
function parsePath(pattern, actual) {
|
|
3647
4666
|
const patternParts = pattern.split("/");
|
|
3648
4667
|
const actualParts = actual.split("/");
|
|
@@ -3805,6 +4824,23 @@ var WS_ROUTES = [
|
|
|
3805
4824
|
httpMethods: ["GET"]
|
|
3806
4825
|
}
|
|
3807
4826
|
];
|
|
4827
|
+
var DISCOVERY_PATHS = /* @__PURE__ */ new Set(["_help", "_routes"]);
|
|
4828
|
+
function allowedMethodsForPath(path, testHooks) {
|
|
4829
|
+
const PREFIX = "/ui-bridge/";
|
|
4830
|
+
if (!path.startsWith(PREFIX)) return [];
|
|
4831
|
+
const stripped = path.slice(PREFIX.length);
|
|
4832
|
+
const methods = [];
|
|
4833
|
+
if (DISCOVERY_PATHS.has(stripped)) methods.push("GET");
|
|
4834
|
+
for (const route of WS_ROUTES) {
|
|
4835
|
+
if (!route.httpMethods) continue;
|
|
4836
|
+
if (route.requiresTestHooks && !testHooks) continue;
|
|
4837
|
+
if (parsePath(route.pattern, stripped) === null) continue;
|
|
4838
|
+
for (const verb of route.httpMethods) {
|
|
4839
|
+
if (!methods.includes(verb)) methods.push(verb);
|
|
4840
|
+
}
|
|
4841
|
+
}
|
|
4842
|
+
return methods;
|
|
4843
|
+
}
|
|
3808
4844
|
var HANDLER_DESCRIPTIONS = {
|
|
3809
4845
|
health: "Liveness probe; returns app metadata + element count",
|
|
3810
4846
|
getElements: "Flat list of registered UI elements",
|
|
@@ -3867,6 +4903,10 @@ var ACTION_REFERENCE = [
|
|
|
3867
4903
|
{ name: "focus", params: "none" },
|
|
3868
4904
|
{ name: "blur", params: "none" },
|
|
3869
4905
|
{ name: "scroll", params: "ScrollActionParams { offset? {x,y}, to? }" },
|
|
4906
|
+
{
|
|
4907
|
+
name: "scrollIntoView",
|
|
4908
|
+
params: "ScrollIntoViewActionParams { padding? (dp, default 16) } \u2014 scrolls the element's DECLARED scrollAncestorId ScrollView; returns { alreadyVisible, scrolled }; NOT_SUPPORTED (501) without a usable declared ancestor"
|
|
4909
|
+
},
|
|
3870
4910
|
{ name: "swipe", params: "SwipeActionParams { direction, distance }" },
|
|
3871
4911
|
{ name: "toggle", params: "none \u2014 flips boolean controls" }
|
|
3872
4912
|
];
|
|
@@ -4064,7 +5104,9 @@ var NativeUIBridgeServer = class {
|
|
|
4064
5104
|
}
|
|
4065
5105
|
/**
|
|
4066
5106
|
* Set a route provider for reporting the current navigation route.
|
|
4067
|
-
* When set, `control/snapshot` responses include `currentRoute` and
|
|
5107
|
+
* When set, `control/snapshot` responses include `currentRoute` and
|
|
5108
|
+
* `segments`, plus the canonical `route` / `activeTab` aliases the web SDK's
|
|
5109
|
+
* `BridgeSnapshot` spells the same way.
|
|
4068
5110
|
*
|
|
4069
5111
|
* Also forwards the provider into the registry so the *default*
|
|
4070
5112
|
* `getSnapshot` handler (and any other call site of
|
|
@@ -4079,7 +5121,12 @@ var NativeUIBridgeServer = class {
|
|
|
4079
5121
|
this.handlers.getElements = async (ctx) => {
|
|
4080
5122
|
const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
|
|
4081
5123
|
if (currentRouteOnly && !ctx.query?.route && !ctx.body?.route) {
|
|
4082
|
-
|
|
5124
|
+
let currentRoute;
|
|
5125
|
+
try {
|
|
5126
|
+
currentRoute = provider.getCurrentRoute();
|
|
5127
|
+
} catch {
|
|
5128
|
+
currentRoute = null;
|
|
5129
|
+
}
|
|
4083
5130
|
if (currentRoute) {
|
|
4084
5131
|
ctx.query = { ...ctx.query, route: currentRoute };
|
|
4085
5132
|
}
|
|
@@ -4089,11 +5136,26 @@ var NativeUIBridgeServer = class {
|
|
|
4089
5136
|
this.handlers.getSnapshot = async (ctx) => {
|
|
4090
5137
|
const visibleOnly = ctx?.query?.visibleOnly === "true" || ctx?.body?.visibleOnly === true;
|
|
4091
5138
|
const currentRouteOnly = ctx?.query?.currentRouteOnly === "true" || ctx?.body?.currentRouteOnly === true;
|
|
5139
|
+
let currentRoute;
|
|
5140
|
+
try {
|
|
5141
|
+
currentRoute = provider.getCurrentRoute() ?? null;
|
|
5142
|
+
} catch {
|
|
5143
|
+
currentRoute = null;
|
|
5144
|
+
}
|
|
5145
|
+
let segments;
|
|
5146
|
+
try {
|
|
5147
|
+
segments = provider.getSegments?.();
|
|
5148
|
+
} catch {
|
|
5149
|
+
segments = void 0;
|
|
5150
|
+
}
|
|
5151
|
+
let activeTab;
|
|
5152
|
+
try {
|
|
5153
|
+
activeTab = provider.getActiveTab?.();
|
|
5154
|
+
} catch {
|
|
5155
|
+
activeTab = void 0;
|
|
5156
|
+
}
|
|
4092
5157
|
const snapshot = this.registry.createSnapshot(
|
|
4093
|
-
{
|
|
4094
|
-
currentRoute: provider.getCurrentRoute(),
|
|
4095
|
-
segments: provider.getSegments?.()
|
|
4096
|
-
},
|
|
5158
|
+
{ currentRoute, segments, activeTab },
|
|
4097
5159
|
{ visibleOnly, currentRouteOnly }
|
|
4098
5160
|
);
|
|
4099
5161
|
return { success: true, data: snapshot, timestamp: Date.now() };
|
|
@@ -4759,8 +5821,15 @@ var NativeUIBridgeServer = class {
|
|
|
4759
5821
|
}
|
|
4760
5822
|
try {
|
|
4761
5823
|
const response = await this.routeRequest(request);
|
|
5824
|
+
if (response.code === "METHOD_NOT_ALLOWED") {
|
|
5825
|
+
const allow = allowedMethodsForPath(request.path, this.config.testHooks === true);
|
|
5826
|
+
if (allow.length > 0) {
|
|
5827
|
+
headers["Allow"] = allow.join(", ");
|
|
5828
|
+
if (this.config.cors) headers["Access-Control-Expose-Headers"] = "Allow";
|
|
5829
|
+
}
|
|
5830
|
+
}
|
|
4762
5831
|
return {
|
|
4763
|
-
status: response
|
|
5832
|
+
status: httpStatusForResponse(response),
|
|
4764
5833
|
headers,
|
|
4765
5834
|
body: JSON.stringify(response)
|
|
4766
5835
|
};
|
|
@@ -4817,7 +5886,7 @@ var NativeUIBridgeServer = class {
|
|
|
4817
5886
|
timestamp: Date.now()
|
|
4818
5887
|
};
|
|
4819
5888
|
}
|
|
4820
|
-
let patternMatchedButWrongVerb =
|
|
5889
|
+
let patternMatchedButWrongVerb = DISCOVERY_PATHS.has(stripped);
|
|
4821
5890
|
for (const route of WS_ROUTES) {
|
|
4822
5891
|
const pathParams = parsePath(route.pattern, stripped);
|
|
4823
5892
|
if (!pathParams) continue;
|
|
@@ -4834,9 +5903,10 @@ var NativeUIBridgeServer = class {
|
|
|
4834
5903
|
const ctx = { params: pathParams, query, body };
|
|
4835
5904
|
return this.handlers[route.handler](ctx);
|
|
4836
5905
|
}
|
|
5906
|
+
const allowed = patternMatchedButWrongVerb ? allowedMethodsForPath(path, testHooks) : [];
|
|
4837
5907
|
return {
|
|
4838
5908
|
success: false,
|
|
4839
|
-
error: patternMatchedButWrongVerb ? `Method not allowed: ${method} ${path}` : `Route not found: ${method} ${path}`,
|
|
5909
|
+
error: patternMatchedButWrongVerb ? `Method not allowed: ${method} ${path}${allowed.length > 0 ? ` (allowed: ${allowed.join(", ")})` : ""}` : `Route not found: ${method} ${path}`,
|
|
4840
5910
|
code: patternMatchedButWrongVerb ? "METHOD_NOT_ALLOWED" : "NOT_FOUND",
|
|
4841
5911
|
timestamp: Date.now()
|
|
4842
5912
|
};
|
|
@@ -5140,8 +6210,52 @@ function createTransportLogger() {
|
|
|
5140
6210
|
}
|
|
5141
6211
|
var transportLogger = createTransportLogger();
|
|
5142
6212
|
|
|
6213
|
+
// src/transport/relay-logging.ts
|
|
6214
|
+
var CREDENTIAL_PARAMS = ["token", "access_token", "auth", "authorization", "key", "sig"];
|
|
6215
|
+
var CREDENTIAL_PARAM_RE = new RegExp(`([?&](?:${CREDENTIAL_PARAMS.join("|")})=)[^&#]*`, "gi");
|
|
6216
|
+
var USERINFO_RE = /^([a-z][a-z0-9+.-]*:\/\/)[^/?#@]*@/i;
|
|
6217
|
+
function redactRelayUrl(url) {
|
|
6218
|
+
return url.replace(USERINFO_RE, "$1<redacted>@").replace(CREDENTIAL_PARAM_RE, "$1<redacted>");
|
|
6219
|
+
}
|
|
6220
|
+
function describeCloseEvent(event) {
|
|
6221
|
+
const e = event ?? {};
|
|
6222
|
+
const code = typeof e.code === "number" ? String(e.code) : "unknown";
|
|
6223
|
+
const reason = typeof e.reason === "string" && e.reason.length > 0 ? e.reason : "(none)";
|
|
6224
|
+
const wasClean = typeof e.wasClean === "boolean" ? String(e.wasClean) : "unknown";
|
|
6225
|
+
return `code=${code} reason=${reason} wasClean=${wasClean}`;
|
|
6226
|
+
}
|
|
6227
|
+
function describeErrorEvent(event) {
|
|
6228
|
+
const e = event ?? {};
|
|
6229
|
+
const parts = [];
|
|
6230
|
+
if (typeof e.message === "string" && e.message.length > 0) parts.push(`message=${e.message}`);
|
|
6231
|
+
if (e.error instanceof Error) parts.push(`error=${e.error.message}`);
|
|
6232
|
+
else if (typeof e.error === "string" && e.error.length > 0) parts.push(`error=${e.error}`);
|
|
6233
|
+
if (typeof e.code === "number") parts.push(`code=${e.code}`);
|
|
6234
|
+
if (parts.length === 0) parts.push("message=(none reported by the transport)");
|
|
6235
|
+
return parts.join(" ");
|
|
6236
|
+
}
|
|
6237
|
+
|
|
5143
6238
|
// src/transport/DeviceAnnouncer.ts
|
|
6239
|
+
function registeredServiceName(result, requested) {
|
|
6240
|
+
if (result !== null && typeof result === "object") {
|
|
6241
|
+
const name = result.name;
|
|
6242
|
+
if (typeof name === "string" && name.length > 0) return name;
|
|
6243
|
+
}
|
|
6244
|
+
return requested;
|
|
6245
|
+
}
|
|
5144
6246
|
var DEFAULT_PORT = 8087;
|
|
6247
|
+
async function releaseZeroconf(zeroconf, name) {
|
|
6248
|
+
if (name !== null) {
|
|
6249
|
+
try {
|
|
6250
|
+
await zeroconf.unpublishService(name);
|
|
6251
|
+
} catch {
|
|
6252
|
+
}
|
|
6253
|
+
}
|
|
6254
|
+
try {
|
|
6255
|
+
zeroconf.removeDeviceListeners?.();
|
|
6256
|
+
} catch {
|
|
6257
|
+
}
|
|
6258
|
+
}
|
|
5145
6259
|
var RECONNECT_INITIAL_DELAY_MS = 2e3;
|
|
5146
6260
|
var RECONNECT_MAX_DELAY_MS = 6e4;
|
|
5147
6261
|
var DeviceAnnouncer = class {
|
|
@@ -5154,8 +6268,16 @@ var DeviceAnnouncer = class {
|
|
|
5154
6268
|
this.reconnectTimer = null;
|
|
5155
6269
|
this.reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
|
|
5156
6270
|
this.stopped = false;
|
|
5157
|
-
/** Zeroconf instance, set when mDNS is
|
|
6271
|
+
/** Zeroconf instance, set when mDNS is started (cleared by `stop()`) */
|
|
5158
6272
|
this.zeroconf = null;
|
|
6273
|
+
/** The name the library registered — the only name this announcer may unpublish */
|
|
6274
|
+
this.publishedName = null;
|
|
6275
|
+
/**
|
|
6276
|
+
* True while a publish is awaiting the library. A `stop()` that lands in that
|
|
6277
|
+
* window leaves the cleanup to the publish path, which is the only side that
|
|
6278
|
+
* will learn the registered name.
|
|
6279
|
+
*/
|
|
6280
|
+
this.publishInFlight = false;
|
|
5159
6281
|
this.config = config;
|
|
5160
6282
|
this.pairingToken = generateHex(16);
|
|
5161
6283
|
}
|
|
@@ -5180,19 +6302,38 @@ var DeviceAnnouncer = class {
|
|
|
5180
6302
|
);
|
|
5181
6303
|
return;
|
|
5182
6304
|
}
|
|
6305
|
+
if (this.stopped) return;
|
|
6306
|
+
let zeroconf = null;
|
|
5183
6307
|
try {
|
|
5184
|
-
|
|
6308
|
+
zeroconf = new ZeroconfCtor();
|
|
6309
|
+
this.zeroconf = zeroconf;
|
|
5185
6310
|
const port = this.config.port ?? DEFAULT_PORT;
|
|
5186
6311
|
const serviceName = `UIBridge-${this.config.deviceId.slice(0, 8)}`;
|
|
5187
|
-
this.
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
6312
|
+
this.publishInFlight = true;
|
|
6313
|
+
let result;
|
|
6314
|
+
try {
|
|
6315
|
+
result = await zeroconf.publishService("_uibridge", "_tcp.", "local.", serviceName, port, {
|
|
6316
|
+
device_id: this.config.deviceId,
|
|
6317
|
+
app_id: this.config.appId,
|
|
6318
|
+
version: this.config.version ?? "unknown",
|
|
6319
|
+
pairing_token: this.pairingToken
|
|
6320
|
+
});
|
|
6321
|
+
} finally {
|
|
6322
|
+
this.publishInFlight = false;
|
|
6323
|
+
}
|
|
6324
|
+
const registered = registeredServiceName(result, serviceName);
|
|
6325
|
+
if (this.stopped) {
|
|
6326
|
+
await releaseZeroconf(zeroconf, registered);
|
|
6327
|
+
return;
|
|
6328
|
+
}
|
|
6329
|
+
this.publishedName = registered;
|
|
5193
6330
|
this.state = { ...this.state, mdnsActive: true };
|
|
5194
|
-
transportLogger.log(`[DeviceAnnouncer] mDNS: advertising "${
|
|
6331
|
+
transportLogger.log(`[DeviceAnnouncer] mDNS: advertising "${registered}" on port ${port}`);
|
|
5195
6332
|
} catch (err) {
|
|
6333
|
+
if (this.stopped) {
|
|
6334
|
+
if (zeroconf) await releaseZeroconf(zeroconf, null);
|
|
6335
|
+
return;
|
|
6336
|
+
}
|
|
5196
6337
|
transportLogger.warn("[DeviceAnnouncer] mDNS start failed:", err);
|
|
5197
6338
|
}
|
|
5198
6339
|
}
|
|
@@ -5209,7 +6350,8 @@ var DeviceAnnouncer = class {
|
|
|
5209
6350
|
}
|
|
5210
6351
|
if (this.stopped) return;
|
|
5211
6352
|
const url = `${this.config.cloudRelayUrl}?token=${encodeURIComponent(this.config.cloudToken)}`;
|
|
5212
|
-
|
|
6353
|
+
const safeUrl = redactRelayUrl(url);
|
|
6354
|
+
transportLogger.log("[DeviceAnnouncer] Cloud relay: connecting to", safeUrl);
|
|
5213
6355
|
const ws = new WebSocket(url);
|
|
5214
6356
|
this.cloudWs = ws;
|
|
5215
6357
|
ws.onopen = () => {
|
|
@@ -5238,14 +6380,22 @@ var DeviceAnnouncer = class {
|
|
|
5238
6380
|
} catch {
|
|
5239
6381
|
}
|
|
5240
6382
|
};
|
|
5241
|
-
ws.onclose = () => {
|
|
6383
|
+
ws.onclose = (event) => {
|
|
5242
6384
|
this.state = { ...this.state, cloudConnected: false };
|
|
5243
|
-
|
|
6385
|
+
const detail = describeCloseEvent(event);
|
|
6386
|
+
if (this.stopped) {
|
|
6387
|
+
transportLogger.log(`[DeviceAnnouncer] Cloud relay closed ${detail} url=${safeUrl}`);
|
|
6388
|
+
} else {
|
|
6389
|
+
transportLogger.warn(
|
|
6390
|
+
`[DeviceAnnouncer] Cloud relay closed unexpectedly ${detail} url=${safeUrl}`
|
|
6391
|
+
);
|
|
5244
6392
|
this.scheduleReconnect();
|
|
5245
6393
|
}
|
|
5246
6394
|
};
|
|
5247
6395
|
ws.onerror = (err) => {
|
|
5248
|
-
transportLogger.warn(
|
|
6396
|
+
transportLogger.warn(
|
|
6397
|
+
`[DeviceAnnouncer] Cloud relay error: ${describeErrorEvent(err)} url=${safeUrl}`
|
|
6398
|
+
);
|
|
5249
6399
|
};
|
|
5250
6400
|
}
|
|
5251
6401
|
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
@@ -5256,15 +6406,15 @@ var DeviceAnnouncer = class {
|
|
|
5256
6406
|
clearTimeout(this.reconnectTimer);
|
|
5257
6407
|
this.reconnectTimer = null;
|
|
5258
6408
|
}
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
5264
|
-
}
|
|
5265
|
-
this.zeroconf = null;
|
|
5266
|
-
}
|
|
6409
|
+
const zeroconf = this.zeroconf;
|
|
6410
|
+
const publishedName = this.publishedName;
|
|
6411
|
+
const inFlight = this.publishInFlight;
|
|
6412
|
+
this.zeroconf = null;
|
|
6413
|
+
this.publishedName = null;
|
|
5267
6414
|
this.state = { ...this.state, mdnsActive: false };
|
|
6415
|
+
if (zeroconf && !inFlight) {
|
|
6416
|
+
await releaseZeroconf(zeroconf, publishedName);
|
|
6417
|
+
}
|
|
5268
6418
|
if (this.cloudWs) {
|
|
5269
6419
|
this.cloudWs.onclose = null;
|
|
5270
6420
|
this.cloudWs.close();
|
|
@@ -5342,7 +6492,8 @@ var CloudRelayClient = class {
|
|
|
5342
6492
|
connect() {
|
|
5343
6493
|
if (this.stopped) return;
|
|
5344
6494
|
const url = `${this.config.relayUrl}?token=${encodeURIComponent(this.config.authToken)}`;
|
|
5345
|
-
|
|
6495
|
+
const safeUrl = redactRelayUrl(url);
|
|
6496
|
+
transportLogger.log("[CloudRelayClient] Connecting to", safeUrl);
|
|
5346
6497
|
const ws = new WebSocket(url);
|
|
5347
6498
|
this.ws = ws;
|
|
5348
6499
|
ws.onopen = () => {
|
|
@@ -5363,14 +6514,22 @@ var CloudRelayClient = class {
|
|
|
5363
6514
|
ws.onmessage = (event) => {
|
|
5364
6515
|
void this.onMessage(event.data);
|
|
5365
6516
|
};
|
|
5366
|
-
ws.onclose = () => {
|
|
6517
|
+
ws.onclose = (event) => {
|
|
5367
6518
|
this._isConnected = false;
|
|
5368
|
-
|
|
6519
|
+
const detail = describeCloseEvent(event);
|
|
6520
|
+
if (this.stopped) {
|
|
6521
|
+
transportLogger.log(`[CloudRelayClient] WebSocket closed ${detail} url=${safeUrl}`);
|
|
6522
|
+
} else {
|
|
6523
|
+
transportLogger.warn(
|
|
6524
|
+
`[CloudRelayClient] WebSocket closed unexpectedly ${detail} url=${safeUrl}`
|
|
6525
|
+
);
|
|
5369
6526
|
this.scheduleReconnect();
|
|
5370
6527
|
}
|
|
5371
6528
|
};
|
|
5372
6529
|
ws.onerror = (err) => {
|
|
5373
|
-
transportLogger.warn(
|
|
6530
|
+
transportLogger.warn(
|
|
6531
|
+
`[CloudRelayClient] WebSocket error: ${describeErrorEvent(err)} url=${safeUrl}`
|
|
6532
|
+
);
|
|
5374
6533
|
};
|
|
5375
6534
|
}
|
|
5376
6535
|
async onMessage(raw) {
|
|
@@ -5649,10 +6808,20 @@ var UndoTracker = class {
|
|
|
5649
6808
|
}
|
|
5650
6809
|
};
|
|
5651
6810
|
var UIBridgeNativeContext = react.createContext(null);
|
|
6811
|
+
var EMPTY_FEATURES = {};
|
|
6812
|
+
var EMPTY_CONFIG = {};
|
|
6813
|
+
function useValueStable(value) {
|
|
6814
|
+
const signature = JSON.stringify(value);
|
|
6815
|
+
return react.useMemo(() => value, [signature]);
|
|
6816
|
+
}
|
|
6817
|
+
function readWindowViewport() {
|
|
6818
|
+
const win = reactNative.Dimensions.get("window");
|
|
6819
|
+
return { width: win.width, height: win.height };
|
|
6820
|
+
}
|
|
5652
6821
|
function UIBridgeNativeProvider({
|
|
5653
6822
|
children,
|
|
5654
|
-
features =
|
|
5655
|
-
config =
|
|
6823
|
+
features: featuresProp = EMPTY_FEATURES,
|
|
6824
|
+
config: configProp = EMPTY_CONFIG,
|
|
5656
6825
|
onEvent,
|
|
5657
6826
|
serverAdapter,
|
|
5658
6827
|
navigationProvider,
|
|
@@ -5661,8 +6830,11 @@ function UIBridgeNativeProvider({
|
|
|
5661
6830
|
routeProvider,
|
|
5662
6831
|
cloudRelayConfig,
|
|
5663
6832
|
enableMdnsAnnounce,
|
|
6833
|
+
zeroconf,
|
|
5664
6834
|
deviceId
|
|
5665
6835
|
}) {
|
|
6836
|
+
const features = useValueStable(featuresProp);
|
|
6837
|
+
const config = useValueStable(configProp);
|
|
5666
6838
|
const registryRef = react.useRef(null);
|
|
5667
6839
|
const executorRef = react.useRef(null);
|
|
5668
6840
|
const modalDetectorRef = react.useRef(null);
|
|
@@ -5675,6 +6847,7 @@ function UIBridgeNativeProvider({
|
|
|
5675
6847
|
onEvent
|
|
5676
6848
|
});
|
|
5677
6849
|
registryRef.current.setPixelRatio(reactNative.PixelRatio.get());
|
|
6850
|
+
registryRef.current.setViewportProvider(readWindowViewport);
|
|
5678
6851
|
setGlobalRegistry(registryRef.current);
|
|
5679
6852
|
}
|
|
5680
6853
|
const registry = registryRef.current;
|
|
@@ -5709,16 +6882,10 @@ function UIBridgeNativeProvider({
|
|
|
5709
6882
|
// the `testHooks` value when undefined, so passing it through verbatim
|
|
5710
6883
|
// keeps existing consumers unchanged.
|
|
5711
6884
|
observability: features.observability,
|
|
5712
|
-
// Injected device-viewport getter for POST /control/page-health
|
|
5713
|
-
//
|
|
5714
|
-
//
|
|
5715
|
-
|
|
5716
|
-
// already has a live react-native import, so it's the safe injection
|
|
5717
|
-
// point. See feedback_metro_require_gotcha for the full incident.
|
|
5718
|
-
viewportProvider: () => {
|
|
5719
|
-
const win = reactNative.Dimensions.get("window");
|
|
5720
|
-
return { width: win.width, height: win.height };
|
|
5721
|
-
}
|
|
6885
|
+
// Injected device-viewport getter for POST /control/page-health and the
|
|
6886
|
+
// design handlers. Same source as the registry's clipping bounds — see
|
|
6887
|
+
// `readWindowViewport`.
|
|
6888
|
+
viewportProvider: readWindowViewport
|
|
5722
6889
|
});
|
|
5723
6890
|
server.setAdapter(serverAdapter);
|
|
5724
6891
|
if (navigationProvider) {
|
|
@@ -5816,10 +6983,7 @@ function UIBridgeNativeProvider({
|
|
|
5816
6983
|
appInfo: config.appInfo,
|
|
5817
6984
|
testHooks: features.testHooks === true,
|
|
5818
6985
|
observability: features.observability,
|
|
5819
|
-
viewportProvider:
|
|
5820
|
-
const win = reactNative.Dimensions.get("window");
|
|
5821
|
-
return { width: win.width, height: win.height };
|
|
5822
|
-
}
|
|
6986
|
+
viewportProvider: readWindowViewport
|
|
5823
6987
|
});
|
|
5824
6988
|
if (navigationProvider) bareServer.setNavigationProvider(navigationProvider);
|
|
5825
6989
|
if (screenshotProvider) bareServer.setScreenshotProvider(screenshotProvider);
|
|
@@ -5840,7 +7004,7 @@ function UIBridgeNativeProvider({
|
|
|
5840
7004
|
cloudToken: cloudRelayConfig?.authToken
|
|
5841
7005
|
});
|
|
5842
7006
|
announcerRef.current = announcer;
|
|
5843
|
-
void announcer.startMdnsAdvertise().catch((err) => {
|
|
7007
|
+
void announcer.startMdnsAdvertise(zeroconf).catch((err) => {
|
|
5844
7008
|
console.warn("[ui-bridge-native] mDNS advertise failed:", err);
|
|
5845
7009
|
});
|
|
5846
7010
|
} catch (err) {
|
|
@@ -5881,6 +7045,7 @@ function UIBridgeNativeProvider({
|
|
|
5881
7045
|
cloudRelayConfig?.relayUrl,
|
|
5882
7046
|
cloudRelayConfig?.authToken,
|
|
5883
7047
|
enableMdnsAnnounce,
|
|
7048
|
+
zeroconf,
|
|
5884
7049
|
deviceId
|
|
5885
7050
|
]);
|
|
5886
7051
|
react.useEffect(() => {
|
|
@@ -6024,6 +7189,7 @@ function useUIElement(options) {
|
|
|
6024
7189
|
const propsRef = react.useRef({});
|
|
6025
7190
|
const registeredRef = react.useRef(false);
|
|
6026
7191
|
const registeredIdRef = react.useRef(null);
|
|
7192
|
+
const autoPublishedLabelRef = react.useRef(void 0);
|
|
6027
7193
|
const bridgeRef = react.useRef(bridge);
|
|
6028
7194
|
bridgeRef.current = bridge;
|
|
6029
7195
|
const nodeRef = react.useRef(null);
|
|
@@ -6063,6 +7229,7 @@ function useUIElement(options) {
|
|
|
6063
7229
|
autoRegister = true,
|
|
6064
7230
|
onStateChange,
|
|
6065
7231
|
parentPath,
|
|
7232
|
+
scrollAncestorId,
|
|
6066
7233
|
style,
|
|
6067
7234
|
stateStyles: stateStylesProp,
|
|
6068
7235
|
handlers: handlersProp,
|
|
@@ -6087,22 +7254,38 @@ function useUIElement(options) {
|
|
|
6087
7254
|
testId: id,
|
|
6088
7255
|
accessibilityLabel: label,
|
|
6089
7256
|
registrationRoute: bridge.getCurrentRoute(),
|
|
7257
|
+
scrollAncestorId,
|
|
6090
7258
|
flatStyle: flattenStyle(style),
|
|
6091
7259
|
stateStyles: flattenStateStyles(stateStylesProp)
|
|
6092
7260
|
});
|
|
6093
7261
|
registeredRef.current = true;
|
|
6094
7262
|
registeredIdRef.current = id;
|
|
7263
|
+
autoPublishedLabelRef.current = typeof label === "string" ? label : void 0;
|
|
6095
7264
|
if (typeof valueProp === "string") {
|
|
6096
7265
|
bridge.registry.updateElementState(id, { value: valueProp });
|
|
6097
7266
|
}
|
|
6098
7267
|
setRegistered(true);
|
|
6099
7268
|
scheduleDeadButtonWarning(bridge, id, type);
|
|
6100
|
-
}, [
|
|
7269
|
+
}, [
|
|
7270
|
+
bridge,
|
|
7271
|
+
id,
|
|
7272
|
+
type,
|
|
7273
|
+
label,
|
|
7274
|
+
actions,
|
|
7275
|
+
customActions,
|
|
7276
|
+
treePath,
|
|
7277
|
+
scrollAncestorId,
|
|
7278
|
+
style,
|
|
7279
|
+
stateStylesProp,
|
|
7280
|
+
valueProp,
|
|
7281
|
+
ref
|
|
7282
|
+
]);
|
|
6101
7283
|
const unregister = react.useCallback(() => {
|
|
6102
7284
|
if (!bridge || !registeredRef.current) return;
|
|
6103
7285
|
bridge.registry.unregisterElement(registeredIdRef.current ?? id);
|
|
6104
7286
|
registeredRef.current = false;
|
|
6105
7287
|
registeredIdRef.current = null;
|
|
7288
|
+
autoPublishedLabelRef.current = void 0;
|
|
6106
7289
|
setRegistered(false);
|
|
6107
7290
|
}, [bridge, id]);
|
|
6108
7291
|
const onStateChangeRef = react.useRef(onStateChange);
|
|
@@ -6130,7 +7313,7 @@ function useUIElement(options) {
|
|
|
6130
7313
|
writeLayout({ x, y, width, height, pageX, pageY });
|
|
6131
7314
|
});
|
|
6132
7315
|
} else {
|
|
6133
|
-
writeLayout({ x, y, width, height, pageX: x, pageY: y });
|
|
7316
|
+
writeLayout({ x, y, width, height, pageX: x, pageY: y, pageOriginUnmeasured: true });
|
|
6134
7317
|
}
|
|
6135
7318
|
},
|
|
6136
7319
|
// `ref` is the once-per-hook interceptor object — identity-stable.
|
|
@@ -6242,6 +7425,16 @@ function useUIElement(options) {
|
|
|
6242
7425
|
if (current === valueProp) return;
|
|
6243
7426
|
bridge.registry.updateElementState(id, { value: valueProp });
|
|
6244
7427
|
}, [bridge, registered, id, valueProp]);
|
|
7428
|
+
react.useEffect(() => {
|
|
7429
|
+
if (!bridge || !registered) return;
|
|
7430
|
+
if (typeof label !== "string") return;
|
|
7431
|
+
autoPublishedLabelRef.current = label;
|
|
7432
|
+
bridge.registry.updateElementMeta(id, {
|
|
7433
|
+
label,
|
|
7434
|
+
accessibilityLabel: label,
|
|
7435
|
+
testId: id
|
|
7436
|
+
});
|
|
7437
|
+
}, [bridge, registered, id, label]);
|
|
6245
7438
|
react.useCallback(
|
|
6246
7439
|
(props) => {
|
|
6247
7440
|
propsRef.current = { ...propsRef.current, ...props };
|
|
@@ -6284,7 +7477,8 @@ function useUIElement(options) {
|
|
|
6284
7477
|
accessibilityLabel: newLabel,
|
|
6285
7478
|
testId: id
|
|
6286
7479
|
});
|
|
6287
|
-
|
|
7480
|
+
const alreadyPublishedBySdk = newLabel === autoPublishedLabelRef.current;
|
|
7481
|
+
if (!changed && !alreadyPublishedBySdk && !warnedRedundantUpdateLabelIds.has(id)) {
|
|
6288
7482
|
warnedRedundantUpdateLabelIds.add(id);
|
|
6289
7483
|
console.warn(
|
|
6290
7484
|
`[UI Bridge] updateLabel("${newLabel}") on element "${id}" was a no-op (label unchanged). Gate the call behind a useEffect with [label] deps to avoid redundant publishes.`
|
|
@@ -6340,36 +7534,56 @@ function useUIElementWithProps(options) {
|
|
|
6340
7534
|
}
|
|
6341
7535
|
function useUIComponent(options) {
|
|
6342
7536
|
const bridge = useUIBridgeNativeOptional();
|
|
7537
|
+
const [registered, setRegistered] = react.useState(false);
|
|
7538
|
+
const [publishVersion, setPublishVersion] = react.useState(0);
|
|
7539
|
+
const publishedIdRef = react.useRef(null);
|
|
6343
7540
|
const registeredRef = react.useRef(false);
|
|
6344
7541
|
const registeredIdRef = react.useRef(null);
|
|
6345
7542
|
const actionsRef = react.useRef(options.actions || []);
|
|
6346
7543
|
const elementIdsRef = react.useRef(options.elementIds || []);
|
|
6347
7544
|
const { id, name, description, autoRegister = true } = options;
|
|
7545
|
+
const actionsSignature = JSON.stringify(
|
|
7546
|
+
(options.actions ?? []).map((a) => [a.id, a.label, a.description, a.paramSchema, a.effect])
|
|
7547
|
+
);
|
|
7548
|
+
const elementIdsSignature = JSON.stringify(options.elementIds ?? []);
|
|
7549
|
+
react.useEffect(() => {
|
|
7550
|
+
if (options.actions !== void 0) actionsRef.current = options.actions;
|
|
7551
|
+
}, [options.actions]);
|
|
6348
7552
|
react.useEffect(() => {
|
|
6349
|
-
|
|
6350
|
-
|
|
6351
|
-
|
|
7553
|
+
if (options.elementIds !== void 0) elementIdsRef.current = options.elementIds;
|
|
7554
|
+
}, [elementIdsSignature]);
|
|
7555
|
+
const toRegistryActions = react.useCallback(
|
|
7556
|
+
() => actionsRef.current.map(({ handler: declaredHandler, ...rest }) => ({
|
|
7557
|
+
...rest,
|
|
7558
|
+
handler: (params, handlerOptions) => {
|
|
7559
|
+
const latest = actionsRef.current.find((a) => a.id === rest.id);
|
|
7560
|
+
return (latest?.handler ?? declaredHandler)(params, handlerOptions);
|
|
7561
|
+
}
|
|
7562
|
+
})),
|
|
7563
|
+
[]
|
|
7564
|
+
);
|
|
6352
7565
|
const register = react.useCallback(() => {
|
|
6353
7566
|
if (!bridge || registeredRef.current) return;
|
|
6354
7567
|
bridge.registry.registerComponent(id, {
|
|
6355
7568
|
name,
|
|
6356
7569
|
description,
|
|
6357
|
-
actions:
|
|
6358
|
-
id: a.id,
|
|
6359
|
-
label: a.label,
|
|
6360
|
-
description: a.description,
|
|
6361
|
-
handler: a.handler
|
|
6362
|
-
})),
|
|
7570
|
+
actions: toRegistryActions(),
|
|
6363
7571
|
elementIds: elementIdsRef.current
|
|
6364
7572
|
});
|
|
6365
7573
|
registeredRef.current = true;
|
|
6366
7574
|
registeredIdRef.current = id;
|
|
6367
|
-
|
|
7575
|
+
setRegistered(true);
|
|
7576
|
+
if (publishedIdRef.current !== id) {
|
|
7577
|
+
publishedIdRef.current = id;
|
|
7578
|
+
setPublishVersion((v) => v + 1);
|
|
7579
|
+
}
|
|
7580
|
+
}, [bridge, id, name, description, toRegistryActions]);
|
|
6368
7581
|
const unregister = react.useCallback(() => {
|
|
6369
7582
|
if (!bridge || !registeredRef.current) return;
|
|
6370
7583
|
bridge.registry.unregisterComponent(registeredIdRef.current ?? id);
|
|
6371
7584
|
registeredRef.current = false;
|
|
6372
7585
|
registeredIdRef.current = null;
|
|
7586
|
+
setRegistered(false);
|
|
6373
7587
|
}, [bridge, id]);
|
|
6374
7588
|
const executeAction = react.useCallback(
|
|
6375
7589
|
async (actionId, params) => {
|
|
@@ -6387,25 +7601,47 @@ function useUIComponent(options) {
|
|
|
6387
7601
|
},
|
|
6388
7602
|
[bridge, id]
|
|
6389
7603
|
);
|
|
7604
|
+
const publishElementIds = react.useCallback(() => {
|
|
7605
|
+
if (!registeredRef.current || !bridge) return;
|
|
7606
|
+
const changed = bridge.registry.updateComponentMeta(registeredIdRef.current ?? id, {
|
|
7607
|
+
elementIds: elementIdsRef.current
|
|
7608
|
+
});
|
|
7609
|
+
if (changed) {
|
|
7610
|
+
setPublishVersion((v) => v + 1);
|
|
7611
|
+
}
|
|
7612
|
+
}, [bridge, id]);
|
|
6390
7613
|
const updateActions = react.useCallback(
|
|
6391
7614
|
(actions) => {
|
|
6392
7615
|
actionsRef.current = actions;
|
|
6393
7616
|
if (registeredRef.current && bridge) {
|
|
6394
|
-
|
|
6395
|
-
|
|
6396
|
-
|
|
7617
|
+
const targetId = registeredIdRef.current ?? id;
|
|
7618
|
+
const changed = bridge.registry.updateComponentMeta(targetId, {
|
|
7619
|
+
actions: toRegistryActions()
|
|
7620
|
+
});
|
|
7621
|
+
if (changed) {
|
|
7622
|
+
setPublishVersion((v) => v + 1);
|
|
7623
|
+
}
|
|
6397
7624
|
}
|
|
6398
7625
|
},
|
|
6399
|
-
[bridge, id,
|
|
7626
|
+
[bridge, id, toRegistryActions]
|
|
7627
|
+
);
|
|
7628
|
+
const addElement = react.useCallback(
|
|
7629
|
+
(elementId) => {
|
|
7630
|
+
if (!elementIdsRef.current.includes(elementId)) {
|
|
7631
|
+
elementIdsRef.current = [...elementIdsRef.current, elementId];
|
|
7632
|
+
publishElementIds();
|
|
7633
|
+
}
|
|
7634
|
+
},
|
|
7635
|
+
[publishElementIds]
|
|
7636
|
+
);
|
|
7637
|
+
const removeElement = react.useCallback(
|
|
7638
|
+
(elementId) => {
|
|
7639
|
+
if (!elementIdsRef.current.includes(elementId)) return;
|
|
7640
|
+
elementIdsRef.current = elementIdsRef.current.filter((eid) => eid !== elementId);
|
|
7641
|
+
publishElementIds();
|
|
7642
|
+
},
|
|
7643
|
+
[publishElementIds]
|
|
6400
7644
|
);
|
|
6401
|
-
const addElement = react.useCallback((elementId) => {
|
|
6402
|
-
if (!elementIdsRef.current.includes(elementId)) {
|
|
6403
|
-
elementIdsRef.current = [...elementIdsRef.current, elementId];
|
|
6404
|
-
}
|
|
6405
|
-
}, []);
|
|
6406
|
-
const removeElement = react.useCallback((elementId) => {
|
|
6407
|
-
elementIdsRef.current = elementIdsRef.current.filter((eid) => eid !== elementId);
|
|
6408
|
-
}, []);
|
|
6409
7645
|
const registerRef = react.useRef(register);
|
|
6410
7646
|
const unregisterRef = react.useRef(unregister);
|
|
6411
7647
|
react.useEffect(() => {
|
|
@@ -6421,13 +7657,32 @@ function useUIComponent(options) {
|
|
|
6421
7657
|
unregisterRef.current();
|
|
6422
7658
|
}
|
|
6423
7659
|
};
|
|
6424
|
-
}, [autoRegister, bridge]);
|
|
6425
|
-
|
|
6426
|
-
if (!bridge) return
|
|
6427
|
-
|
|
6428
|
-
|
|
7660
|
+
}, [autoRegister, bridge, id]);
|
|
7661
|
+
react.useEffect(() => {
|
|
7662
|
+
if (!bridge || !registered) return;
|
|
7663
|
+
const targetId = registeredIdRef.current ?? id;
|
|
7664
|
+
const changed = bridge.registry.updateComponentMeta(targetId, {
|
|
7665
|
+
name,
|
|
7666
|
+
description,
|
|
7667
|
+
...options.actions !== void 0 ? { actions: toRegistryActions() } : {},
|
|
7668
|
+
...options.elementIds !== void 0 ? { elementIds: elementIdsRef.current } : {}
|
|
7669
|
+
});
|
|
7670
|
+
if (changed) {
|
|
7671
|
+
setPublishVersion((v) => v + 1);
|
|
7672
|
+
}
|
|
7673
|
+
}, [
|
|
7674
|
+
bridge,
|
|
7675
|
+
registered,
|
|
7676
|
+
id,
|
|
7677
|
+
name,
|
|
7678
|
+
description,
|
|
7679
|
+
actionsSignature,
|
|
7680
|
+
elementIdsSignature,
|
|
7681
|
+
toRegistryActions
|
|
7682
|
+
]);
|
|
7683
|
+
const registeredComponent = registered && bridge ? bridge.registry.getComponent(id) ?? null : null;
|
|
6429
7684
|
return {
|
|
6430
|
-
registered
|
|
7685
|
+
registered,
|
|
6431
7686
|
executeAction,
|
|
6432
7687
|
register,
|
|
6433
7688
|
unregister,
|