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