@qontinui/ui-bridge-native 0.6.11 → 0.6.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +853 -15
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +835 -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 +317 -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 +1603 -118
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1585 -119
- package/dist/index.mjs.map +1 -1
- package/dist/react/UIBridgeNativeProvider.d.ts +1 -23
- package/dist/react/UIBridgeNativeProvider.d.ts.map +1 -1
- package/dist/react/index.js +1420 -113
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +1420 -113
- 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 +348 -43
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +348 -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.map +1 -1
- package/dist/transport/relay-logging.d.ts +50 -0
- package/dist/transport/relay-logging.d.ts.map +1 -0
- package/package.json +2 -2
package/dist/react/index.js
CHANGED
|
@@ -184,7 +184,42 @@ function projectVisionFields(args) {
|
|
|
184
184
|
return fields;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
// src/core/element-actions.ts
|
|
188
|
+
function serializeElementCustomActions(customActions) {
|
|
189
|
+
if (!customActions) return void 0;
|
|
190
|
+
const out = [];
|
|
191
|
+
for (const [id, action] of Object.entries(customActions)) {
|
|
192
|
+
if (!action) continue;
|
|
193
|
+
const entry = { id };
|
|
194
|
+
if (action.label !== void 0) entry.label = action.label;
|
|
195
|
+
if (action.description !== void 0) entry.description = action.description;
|
|
196
|
+
if (action.effect !== void 0) entry.effect = action.effect;
|
|
197
|
+
out.push(entry);
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
187
202
|
// src/core/registry.ts
|
|
203
|
+
function toRegisteredComponentActions(actions) {
|
|
204
|
+
return actions.map((a) => ({
|
|
205
|
+
id: a.id,
|
|
206
|
+
label: a.label,
|
|
207
|
+
description: a.description,
|
|
208
|
+
paramSchema: a.paramSchema,
|
|
209
|
+
effect: a.effect,
|
|
210
|
+
handler: a.handler
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
function publishedActionSignature(actions) {
|
|
214
|
+
return JSON.stringify(
|
|
215
|
+
actions.map((a) => [a.id, a.label, a.description, a.paramSchema, a.effect])
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
function sameElementIds(a, b) {
|
|
219
|
+
if (a === b) return true;
|
|
220
|
+
if (a === void 0 || b === void 0) return false;
|
|
221
|
+
return a.length === b.length && a.every((id, i) => id === b[i]);
|
|
222
|
+
}
|
|
188
223
|
function extractHandlerNames(props) {
|
|
189
224
|
if (!props) return [];
|
|
190
225
|
return Object.keys(props).filter((k) => typeof props[k] === "function");
|
|
@@ -193,6 +228,22 @@ function matchesCurrentRoute(registrationRoute, currentRoute) {
|
|
|
193
228
|
if (registrationRoute == null || registrationRoute === "") return true;
|
|
194
229
|
return registrationRoute === currentRoute;
|
|
195
230
|
}
|
|
231
|
+
function deriveActiveTabFromSegments(segments) {
|
|
232
|
+
if (!segments || segments.length === 0) return void 0;
|
|
233
|
+
let innermostGroup = -1;
|
|
234
|
+
for (let i = 0; i < segments.length; i++) {
|
|
235
|
+
const segment = segments[i];
|
|
236
|
+
if (typeof segment === "string" && segment.startsWith("(") && segment.endsWith(")")) {
|
|
237
|
+
innermostGroup = i;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (innermostGroup === -1) return void 0;
|
|
241
|
+
const next = segments[innermostGroup + 1];
|
|
242
|
+
if (next === void 0) return "index";
|
|
243
|
+
if (typeof next !== "string" || next.length === 0) return void 0;
|
|
244
|
+
if (next.startsWith("[") && next.endsWith("]")) return void 0;
|
|
245
|
+
return next;
|
|
246
|
+
}
|
|
196
247
|
function projectBbox(state, visibility, pixelRatio = 1) {
|
|
197
248
|
if (visibility !== "visible") return void 0;
|
|
198
249
|
const layout = state.layout;
|
|
@@ -212,8 +263,58 @@ function projectBbox(state, visibility, pixelRatio = 1) {
|
|
|
212
263
|
h: toPx(layout.height)
|
|
213
264
|
};
|
|
214
265
|
}
|
|
215
|
-
function
|
|
216
|
-
const
|
|
266
|
+
function pageRectOf(state) {
|
|
267
|
+
const layout = state.layout;
|
|
268
|
+
if (!layout) return null;
|
|
269
|
+
if (layout.pageOriginUnmeasured === true) return null;
|
|
270
|
+
const hasPage = typeof layout.pageX === "number" && typeof layout.pageY === "number" && Number.isFinite(layout.pageX) && Number.isFinite(layout.pageY);
|
|
271
|
+
if (!hasPage) return null;
|
|
272
|
+
const left = layout.pageX;
|
|
273
|
+
const top = layout.pageY;
|
|
274
|
+
if (typeof layout.width !== "number" || typeof layout.height !== "number" || !Number.isFinite(layout.width) || !Number.isFinite(layout.height)) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
return { left, top, right: left + layout.width, bottom: top + layout.height };
|
|
278
|
+
}
|
|
279
|
+
function spansOverlap(aMin, aMax, bMin, bMax) {
|
|
280
|
+
if (aMin === aMax) return aMin >= bMin && aMin <= bMax;
|
|
281
|
+
return aMin < bMax && aMax > bMin;
|
|
282
|
+
}
|
|
283
|
+
function isEmptyRect(rect) {
|
|
284
|
+
return rect.right <= rect.left || rect.bottom <= rect.top;
|
|
285
|
+
}
|
|
286
|
+
function intersectRects(a, b) {
|
|
287
|
+
if (!a) return b;
|
|
288
|
+
if (!b) return a;
|
|
289
|
+
return {
|
|
290
|
+
left: Math.max(a.left, b.left),
|
|
291
|
+
top: Math.max(a.top, b.top),
|
|
292
|
+
right: Math.min(a.right, b.right),
|
|
293
|
+
bottom: Math.min(a.bottom, b.bottom)
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function computeVisibility(state, clip) {
|
|
297
|
+
if (!state.visible) {
|
|
298
|
+
return { visibility: "hidden", visibilityReason: "hidden" };
|
|
299
|
+
}
|
|
300
|
+
if (state.layout === null) {
|
|
301
|
+
return { visibility: "likely-visible", visibilityReason: "no-layout" };
|
|
302
|
+
}
|
|
303
|
+
if (!clip) {
|
|
304
|
+
return { visibility: "visible" };
|
|
305
|
+
}
|
|
306
|
+
const rect = pageRectOf(state);
|
|
307
|
+
if (!rect) {
|
|
308
|
+
return { visibility: "visible" };
|
|
309
|
+
}
|
|
310
|
+
if (isEmptyRect(clip)) {
|
|
311
|
+
return { visibility: "hidden", visibilityReason: "off-screen" };
|
|
312
|
+
}
|
|
313
|
+
const onScreen = spansOverlap(rect.left, rect.right, clip.left, clip.right) && spansOverlap(rect.top, rect.bottom, clip.top, clip.bottom);
|
|
314
|
+
return onScreen ? { visibility: "visible" } : { visibility: "hidden", visibilityReason: "off-screen" };
|
|
315
|
+
}
|
|
316
|
+
function inferActions(type, scrollAncestorId) {
|
|
317
|
+
const baseActions = scrollAncestorId ? ["focus", "blur", "scrollIntoView"] : ["focus", "blur"];
|
|
217
318
|
switch (type) {
|
|
218
319
|
case "button":
|
|
219
320
|
case "touchable":
|
|
@@ -285,6 +386,18 @@ var NativeUIBridgeRegistry = class {
|
|
|
285
386
|
* the `projectBbox` doc comment for why we don't import `react-native` here.
|
|
286
387
|
*/
|
|
287
388
|
this.pixelRatio = 1;
|
|
389
|
+
/**
|
|
390
|
+
* Injected getter for the device window size in logical dp, used to clip
|
|
391
|
+
* reported visibility. Injected rather than read here for exactly the reason
|
|
392
|
+
* `pixelRatio` is: a static `import { Dimensions } from 'react-native'` in
|
|
393
|
+
* this non-React core module breaks the vitest suite and risks the
|
|
394
|
+
* Metro/Hermes `unknownModuleError`. It is a PROVIDER, not a value, so a
|
|
395
|
+
* rotation is picked up on the next snapshot with nothing to keep in sync.
|
|
396
|
+
*
|
|
397
|
+
* `null` means the window bounds are UNKNOWN, and {@link computeVisibility}
|
|
398
|
+
* treats unknown bounds as "cannot demote" — never as an empty viewport.
|
|
399
|
+
*/
|
|
400
|
+
this.viewportProvider = null;
|
|
288
401
|
/**
|
|
289
402
|
* Sticky flag: flips `true` the first time any element is registered and
|
|
290
403
|
* stays `true` even after elements are unregistered. Lets agents distinguish
|
|
@@ -322,6 +435,30 @@ var NativeUIBridgeRegistry = class {
|
|
|
322
435
|
getRouteProvider() {
|
|
323
436
|
return this.routeProvider;
|
|
324
437
|
}
|
|
438
|
+
/**
|
|
439
|
+
* Resolve the snapshot's `activeTab`: an explicit `getActiveTab()` on the
|
|
440
|
+
* route provider wins, otherwise derive it from the Expo Router segments the
|
|
441
|
+
* provider already exposes.
|
|
442
|
+
*
|
|
443
|
+
* A throwing or empty-string provider degrades to the derivation rather than
|
|
444
|
+
* to `undefined` — the host's opt-in extra should never be able to make the
|
|
445
|
+
* snapshot WORSE than it was without it. A blank derivation stays blank.
|
|
446
|
+
*/
|
|
447
|
+
resolveActiveTab(segments) {
|
|
448
|
+
const explicit = (() => {
|
|
449
|
+
if (!this.routeProvider?.getActiveTab) return void 0;
|
|
450
|
+
try {
|
|
451
|
+
const value = this.routeProvider.getActiveTab();
|
|
452
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
453
|
+
} catch (error3) {
|
|
454
|
+
if (this.config.verbose) {
|
|
455
|
+
console.warn(`[ui-bridge-native] routeProvider getActiveTab threw:`, error3);
|
|
456
|
+
}
|
|
457
|
+
return void 0;
|
|
458
|
+
}
|
|
459
|
+
})();
|
|
460
|
+
return explicit ?? deriveActiveTabFromSegments(segments);
|
|
461
|
+
}
|
|
325
462
|
/**
|
|
326
463
|
* Set the device pixel ratio used to project per-element `bbox` geometry
|
|
327
464
|
* into physical pixels. The React provider passes `PixelRatio.get()` here
|
|
@@ -337,6 +474,56 @@ var NativeUIBridgeRegistry = class {
|
|
|
337
474
|
getPixelRatio() {
|
|
338
475
|
return this.pixelRatio;
|
|
339
476
|
}
|
|
477
|
+
/**
|
|
478
|
+
* Set the getter for the device window size (logical dp) that reported
|
|
479
|
+
* visibility is clipped against. The React provider passes
|
|
480
|
+
* `() => Dimensions.get('window')` here at construction.
|
|
481
|
+
*
|
|
482
|
+
* Pass `null` to detach. While detached the window bounds are UNKNOWN and no
|
|
483
|
+
* element is ever demoted for being outside them.
|
|
484
|
+
*/
|
|
485
|
+
setViewportProvider(provider) {
|
|
486
|
+
this.viewportProvider = provider ?? null;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* The window as a page-space rect, or `null` when it is unknown or the
|
|
490
|
+
* provider returned something unusable (it throws in some RN teardown
|
|
491
|
+
* paths, and a zero-size window would silently hide the entire screen).
|
|
492
|
+
*/
|
|
493
|
+
getViewportRect() {
|
|
494
|
+
if (!this.viewportProvider) return null;
|
|
495
|
+
let size;
|
|
496
|
+
try {
|
|
497
|
+
size = this.viewportProvider();
|
|
498
|
+
} catch (error3) {
|
|
499
|
+
if (this.config.verbose) {
|
|
500
|
+
console.warn(`[ui-bridge-native] viewportProvider threw:`, error3);
|
|
501
|
+
}
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
const { width, height } = size ?? {};
|
|
505
|
+
if (typeof width !== "number" || typeof height !== "number" || !Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
return { left: 0, top: 0, right: width, bottom: height };
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* The region `element` must reach to count as on screen: the window,
|
|
512
|
+
* intersected with the measured frame of its declared `scrollAncestorId`.
|
|
513
|
+
*
|
|
514
|
+
* Returns `null` when neither bound is known — see {@link computeVisibility}
|
|
515
|
+
* for why that means "do not demote" rather than "nothing is visible".
|
|
516
|
+
* An ancestor that is not registered, or registered but not yet measured,
|
|
517
|
+
* contributes nothing rather than hiding its subtree.
|
|
518
|
+
*/
|
|
519
|
+
getClipRectFor(element, viewport) {
|
|
520
|
+
const bounds = viewport === void 0 ? this.getViewportRect() : viewport;
|
|
521
|
+
const ancestorId = element.scrollAncestorId;
|
|
522
|
+
if (!ancestorId) return bounds;
|
|
523
|
+
const ancestor = this.elements.get(ancestorId);
|
|
524
|
+
if (!ancestor || ancestorId === element.id) return bounds;
|
|
525
|
+
return intersectRects(bounds, pageRectOf(ancestor.getState()));
|
|
526
|
+
}
|
|
340
527
|
// ============================================================================
|
|
341
528
|
// Snapshot Enricher Slots
|
|
342
529
|
// ============================================================================
|
|
@@ -380,16 +567,17 @@ var NativeUIBridgeRegistry = class {
|
|
|
380
567
|
const {
|
|
381
568
|
type = "custom",
|
|
382
569
|
label,
|
|
383
|
-
actions = inferActions(type),
|
|
384
570
|
customActions,
|
|
385
571
|
props,
|
|
386
572
|
treePath = id,
|
|
387
573
|
testId,
|
|
388
574
|
accessibilityLabel,
|
|
389
575
|
registrationRoute,
|
|
576
|
+
scrollAncestorId,
|
|
390
577
|
flatStyle,
|
|
391
578
|
stateStyles
|
|
392
579
|
} = options;
|
|
580
|
+
const actions = options.actions ?? inferActions(type, scrollAncestorId);
|
|
393
581
|
const getState = () => {
|
|
394
582
|
const element = ref.current;
|
|
395
583
|
if (!element) {
|
|
@@ -432,6 +620,7 @@ var NativeUIBridgeRegistry = class {
|
|
|
432
620
|
registeredAt: Date.now(),
|
|
433
621
|
mounted: true,
|
|
434
622
|
registrationRoute: registrationRoute ?? null,
|
|
623
|
+
scrollAncestorId,
|
|
435
624
|
flatStyle,
|
|
436
625
|
stateStyles
|
|
437
626
|
};
|
|
@@ -562,6 +751,103 @@ var NativeUIBridgeRegistry = class {
|
|
|
562
751
|
console.log(`[ui-bridge-native] Marked ${cleared} elements offscreen for route: ${route}`);
|
|
563
752
|
}
|
|
564
753
|
}
|
|
754
|
+
/**
|
|
755
|
+
* Re-measure every mounted-visible element's geometry via its stored React
|
|
756
|
+
* ref and write the fresh coordinates into the registry ("measure-on-
|
|
757
|
+
* snapshot").
|
|
758
|
+
*
|
|
759
|
+
* Why: element geometry is otherwise captured ONCE at mount time
|
|
760
|
+
* (`onLayout` + `measureInWindow` in `useUIElement`) and RN never re-fires
|
|
761
|
+
* `onLayout` on pure translation — scrolling a virtualized list
|
|
762
|
+
* (FlashList), recycling a cell, a keyboard shift or an orientation change
|
|
763
|
+
* all move elements WITHOUT updating the stored `state.layout`. Snapshot
|
|
764
|
+
* consumers (vision analyzers hit-testing `bbox`, coord-based tap) then
|
|
765
|
+
* operate on stale positions. Re-measuring at READ time is correct
|
|
766
|
+
* regardless of what moved the element and needs zero per-consumer wiring:
|
|
767
|
+
* every registry entry already stores its ref (see `registerElement`).
|
|
768
|
+
*
|
|
769
|
+
* Per-element outcomes:
|
|
770
|
+
* - non-zero dims → write a fresh full layout (same state shape as the
|
|
771
|
+
* mount-time measure loop in `useUIElement`); counted as `measured`.
|
|
772
|
+
* - zero dims AND the element previously had a non-null `state.layout` →
|
|
773
|
+
* the view collapsed / left the screen; clear to
|
|
774
|
+
* `{ visible: false, layout: null }` (same shape `markRouteOffscreen`
|
|
775
|
+
* writes); counted as `cleared`.
|
|
776
|
+
* - zero dims and it never had a layout → leave state untouched (element
|
|
777
|
+
* in the mount→first-onLayout gap; don't demote `likely-visible`);
|
|
778
|
+
* counted as `skipped`.
|
|
779
|
+
* - no ref / no callable `measureInWindow` (test fixtures, web) → leave
|
|
780
|
+
* untouched; counted as `skipped`.
|
|
781
|
+
*
|
|
782
|
+
* The whole sweep races an overall timeout (default 250 ms) so a dead ref
|
|
783
|
+
* whose `measureInWindow` never calls back can never hang a snapshot: the
|
|
784
|
+
* returned promise resolves at the deadline with whatever counts have
|
|
785
|
+
* accumulated. Late callbacks may still write state afterwards — harmless,
|
|
786
|
+
* freshest data wins. Never throws.
|
|
787
|
+
*
|
|
788
|
+
* Non-React core module on purpose — `measureInWindow` is duck-typed off
|
|
789
|
+
* the stored ref rather than imported from `react-native` (see the
|
|
790
|
+
* `projectBbox` doc comment for why this file must not import RN).
|
|
791
|
+
*/
|
|
792
|
+
async refreshMeasurements(options) {
|
|
793
|
+
const timeoutMs = typeof options?.timeoutMs === "number" && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 250;
|
|
794
|
+
const counts = { measured: 0, cleared: 0, skipped: 0 };
|
|
795
|
+
const pending = [];
|
|
796
|
+
for (const element of this.getMountedVisibleElements()) {
|
|
797
|
+
const node = element.ref?.current;
|
|
798
|
+
if (!node || typeof node.measureInWindow !== "function") {
|
|
799
|
+
counts.skipped++;
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
const id = element.id;
|
|
803
|
+
pending.push(
|
|
804
|
+
new Promise((resolve) => {
|
|
805
|
+
let settled = false;
|
|
806
|
+
try {
|
|
807
|
+
node.measureInWindow((pageX, pageY, w, h) => {
|
|
808
|
+
if (settled) return;
|
|
809
|
+
settled = true;
|
|
810
|
+
try {
|
|
811
|
+
if (w > 0 && h > 0) {
|
|
812
|
+
this.updateElementState(id, {
|
|
813
|
+
mounted: true,
|
|
814
|
+
visible: true,
|
|
815
|
+
enabled: true,
|
|
816
|
+
focused: false,
|
|
817
|
+
layout: { x: pageX, y: pageY, width: w, height: h, pageX, pageY }
|
|
818
|
+
});
|
|
819
|
+
counts.measured++;
|
|
820
|
+
} else if (this.elements.get(id)?.getState().layout != null) {
|
|
821
|
+
this.updateElementState(id, { visible: false, layout: null });
|
|
822
|
+
counts.cleared++;
|
|
823
|
+
} else {
|
|
824
|
+
counts.skipped++;
|
|
825
|
+
}
|
|
826
|
+
} finally {
|
|
827
|
+
resolve();
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
} catch {
|
|
831
|
+
if (!settled) {
|
|
832
|
+
settled = true;
|
|
833
|
+
counts.skipped++;
|
|
834
|
+
resolve();
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
})
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
if (pending.length > 0) {
|
|
841
|
+
await new Promise((resolve) => {
|
|
842
|
+
const timer = setTimeout(resolve, timeoutMs);
|
|
843
|
+
void Promise.all(pending).then(() => {
|
|
844
|
+
clearTimeout(timer);
|
|
845
|
+
resolve();
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
return counts;
|
|
850
|
+
}
|
|
565
851
|
/**
|
|
566
852
|
* Update element state
|
|
567
853
|
*/
|
|
@@ -711,12 +997,8 @@ var NativeUIBridgeRegistry = class {
|
|
|
711
997
|
id,
|
|
712
998
|
name,
|
|
713
999
|
description,
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
label: a.label,
|
|
717
|
-
description: a.description,
|
|
718
|
-
handler: a.handler
|
|
719
|
-
})),
|
|
1000
|
+
// Closed field list, applied once — see `toRegisteredComponentActions`.
|
|
1001
|
+
actions: toRegisteredComponentActions(actions),
|
|
720
1002
|
elementIds,
|
|
721
1003
|
registeredAt: Date.now(),
|
|
722
1004
|
mounted: true
|
|
@@ -728,6 +1010,55 @@ var NativeUIBridgeRegistry = class {
|
|
|
728
1010
|
}
|
|
729
1011
|
return registered;
|
|
730
1012
|
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Update a registered component's declaration in place — name, description,
|
|
1015
|
+
* actions, elementIds.
|
|
1016
|
+
*
|
|
1017
|
+
* Sister to {@link updateElementMeta}, and it exists for the same reason one
|
|
1018
|
+
* level up. `registerComponent` publishes the declaration exactly once, and
|
|
1019
|
+
* `useUIComponent` registers on mount and never again, so an action whose
|
|
1020
|
+
* `label` carries a count, an action that only exists while a row is
|
|
1021
|
+
* selected, a `paramSchema` that widens once options load, or an `elementIds`
|
|
1022
|
+
* list that grows after mount, all published their mount-time shape to
|
|
1023
|
+
* `/control/components` forever. PR #176 closed that hole for an ELEMENT's
|
|
1024
|
+
* label; this closes it for a COMPONENT's declaration.
|
|
1025
|
+
*
|
|
1026
|
+
* **Idempotent by VALUE over the PUBLISHED shape.** `handler` identity is
|
|
1027
|
+
* excluded from the comparison — call sites pass inline closures that are
|
|
1028
|
+
* freshly allocated every render, so including it would report a change on
|
|
1029
|
+
* every call and make the door useless for a render-path publisher. The
|
|
1030
|
+
* newest handlers are still STORED whenever `actions` is supplied, which is
|
|
1031
|
+
* the other half of the same defect: without it the registry keeps the
|
|
1032
|
+
* closure from the render that registered the component, so a handler reading
|
|
1033
|
+
* component state read the state as it was at mount.
|
|
1034
|
+
*
|
|
1035
|
+
* Returns `true` when the published shape changed and an event was emitted,
|
|
1036
|
+
* `false` otherwise — the same contract as `updateElementMeta`, so a caller
|
|
1037
|
+
* can gate a warning or a state write on a real change.
|
|
1038
|
+
*/
|
|
1039
|
+
updateComponentMeta(id, meta) {
|
|
1040
|
+
const component = this.components.get(id);
|
|
1041
|
+
if (!component) return false;
|
|
1042
|
+
const nextName = meta.name !== void 0 ? meta.name : component.name;
|
|
1043
|
+
const nextDescription = meta.description !== void 0 ? meta.description : component.description;
|
|
1044
|
+
const nextActions = meta.actions !== void 0 ? toRegisteredComponentActions(meta.actions) : component.actions;
|
|
1045
|
+
const nextElementIds = meta.elementIds !== void 0 ? meta.elementIds : component.elementIds;
|
|
1046
|
+
const changed = nextName !== component.name || nextDescription !== component.description || !sameElementIds(nextElementIds, component.elementIds) || publishedActionSignature(nextActions) !== publishedActionSignature(component.actions);
|
|
1047
|
+
if (!changed && meta.actions === void 0) return false;
|
|
1048
|
+
this.components.set(id, {
|
|
1049
|
+
...component,
|
|
1050
|
+
name: nextName,
|
|
1051
|
+
description: nextDescription,
|
|
1052
|
+
actions: nextActions,
|
|
1053
|
+
elementIds: nextElementIds
|
|
1054
|
+
});
|
|
1055
|
+
if (!changed) return false;
|
|
1056
|
+
this.emit("component:registered", { id, name: nextName });
|
|
1057
|
+
if (this.config.verbose) {
|
|
1058
|
+
console.log(`[ui-bridge-native] Updated component: ${id} (${nextName})`);
|
|
1059
|
+
}
|
|
1060
|
+
return true;
|
|
1061
|
+
}
|
|
731
1062
|
/**
|
|
732
1063
|
* Unregister a component
|
|
733
1064
|
*/
|
|
@@ -841,41 +1172,59 @@ var NativeUIBridgeRegistry = class {
|
|
|
841
1172
|
* server-level override (the bare-server / cloud-relay path) still get a
|
|
842
1173
|
* populated `currentRoute` field as long as `setRouteProvider` was called
|
|
843
1174
|
* on the registry.
|
|
1175
|
+
*
|
|
1176
|
+
* `activeTab` resolves through {@link resolveActiveTab} on BOTH branches. An
|
|
1177
|
+
* explicit `routeInfo.activeTab` wins outright; otherwise a registered
|
|
1178
|
+
* provider's `getActiveTab()` answers and the Expo Router derivation is the
|
|
1179
|
+
* fallback. Deriving straight from `routeInfo.segments` here instead — which
|
|
1180
|
+
* is what this branch did when `activeTab` was introduced — silently ignored
|
|
1181
|
+
* `RouteProvider.getActiveTab` on the ONLY path that serves
|
|
1182
|
+
* `control/snapshot` once `setRouteProvider` is wired (the server installs a
|
|
1183
|
+
* `getSnapshot` override that passes `routeInfo`), so the opt-in hook for an
|
|
1184
|
+
* app whose visible pane is decoupled from the router reached no consumer.
|
|
844
1185
|
*/
|
|
845
1186
|
createSnapshot(routeInfo, options) {
|
|
846
1187
|
const resolvedRoute = (() => {
|
|
847
1188
|
if (routeInfo !== void 0) {
|
|
1189
|
+
const explicit = typeof routeInfo.activeTab === "string" && routeInfo.activeTab.length > 0 ? routeInfo.activeTab : void 0;
|
|
848
1190
|
return {
|
|
849
1191
|
currentRoute: routeInfo.currentRoute ?? null,
|
|
850
|
-
segments: routeInfo.segments
|
|
1192
|
+
segments: routeInfo.segments,
|
|
1193
|
+
activeTab: explicit ?? this.resolveActiveTab(routeInfo.segments)
|
|
851
1194
|
};
|
|
852
1195
|
}
|
|
853
1196
|
if (this.routeProvider) {
|
|
854
1197
|
try {
|
|
1198
|
+
const segments = this.routeProvider.getSegments?.();
|
|
855
1199
|
return {
|
|
856
1200
|
currentRoute: this.routeProvider.getCurrentRoute() ?? null,
|
|
857
|
-
segments
|
|
1201
|
+
segments,
|
|
1202
|
+
activeTab: this.resolveActiveTab(segments)
|
|
858
1203
|
};
|
|
859
1204
|
} catch (error3) {
|
|
860
1205
|
if (this.config.verbose) {
|
|
861
1206
|
console.warn(`[ui-bridge-native] routeProvider getCurrentRoute threw:`, error3);
|
|
862
1207
|
}
|
|
863
|
-
return { currentRoute: null, segments: void 0 };
|
|
1208
|
+
return { currentRoute: null, segments: void 0, activeTab: void 0 };
|
|
864
1209
|
}
|
|
865
1210
|
}
|
|
866
|
-
return { currentRoute: null, segments: void 0 };
|
|
1211
|
+
return { currentRoute: null, segments: void 0, activeTab: void 0 };
|
|
867
1212
|
})();
|
|
868
1213
|
let elements = options?.visibleOnly ? this.getMountedVisibleElements() : this.getAllElements();
|
|
869
1214
|
if (options?.currentRouteOnly && resolvedRoute.currentRoute) {
|
|
870
1215
|
const currentRoute = resolvedRoute.currentRoute;
|
|
871
1216
|
elements = elements.filter((e) => matchesCurrentRoute(e.registrationRoute, currentRoute));
|
|
872
1217
|
}
|
|
1218
|
+
const viewport = this.getViewportRect();
|
|
873
1219
|
const snapshot = {
|
|
874
1220
|
timestamp: Date.now(),
|
|
875
1221
|
elements: elements.map((e) => {
|
|
876
1222
|
const handlers = extractHandlerNames(e.props);
|
|
877
1223
|
const state = e.getState();
|
|
878
|
-
const visibility
|
|
1224
|
+
const { visibility, visibilityReason } = computeVisibility(
|
|
1225
|
+
state,
|
|
1226
|
+
this.getClipRectFor(e, viewport)
|
|
1227
|
+
);
|
|
879
1228
|
const bbox = projectBbox(state, visibility, this.pixelRatio);
|
|
880
1229
|
const vision = projectVisionFields({
|
|
881
1230
|
type: e.type,
|
|
@@ -895,10 +1244,11 @@ var NativeUIBridgeRegistry = class {
|
|
|
895
1244
|
identifier: e.getIdentifier(),
|
|
896
1245
|
state,
|
|
897
1246
|
actions: e.actions,
|
|
898
|
-
customActions:
|
|
1247
|
+
customActions: serializeElementCustomActions(e.customActions),
|
|
899
1248
|
registeredHandlers: handlers.length > 0 ? handlers : void 0,
|
|
900
1249
|
registrationRoute: e.registrationRoute,
|
|
901
1250
|
visibility,
|
|
1251
|
+
...visibilityReason ? { visibilityReason } : {},
|
|
902
1252
|
...bbox ? { bbox } : {},
|
|
903
1253
|
...vision
|
|
904
1254
|
};
|
|
@@ -918,6 +1268,13 @@ var NativeUIBridgeRegistry = class {
|
|
|
918
1268
|
})),
|
|
919
1269
|
currentRoute: resolvedRoute.currentRoute,
|
|
920
1270
|
segments: resolvedRoute.segments,
|
|
1271
|
+
// Canonical aliases, spelled the way the web SDK's `BridgeSnapshot`
|
|
1272
|
+
// spells them so a cross-platform consumer reads one field name on both
|
|
1273
|
+
// platforms. Omitted rather than nulled when unknown — same convention
|
|
1274
|
+
// as the web fields, and it keeps the emitted shape byte-identical for
|
|
1275
|
+
// hosts that wire no route provider.
|
|
1276
|
+
...resolvedRoute.currentRoute ? { route: resolvedRoute.currentRoute } : {},
|
|
1277
|
+
...resolvedRoute.activeTab ? { activeTab: resolvedRoute.activeTab } : {},
|
|
921
1278
|
registration: this.getRegistrationCoverage()
|
|
922
1279
|
};
|
|
923
1280
|
if (this.appInfo) {
|
|
@@ -1063,6 +1420,437 @@ function findByPattern(registry, pattern) {
|
|
|
1063
1420
|
return null;
|
|
1064
1421
|
}
|
|
1065
1422
|
|
|
1423
|
+
// src/core/abortable.ts
|
|
1424
|
+
var MAX_ACTION_TIMEOUT_MS = 864e5;
|
|
1425
|
+
function normalizeActionTimeoutMs(raw) {
|
|
1426
|
+
if (raw === void 0 || raw === null) {
|
|
1427
|
+
return { ok: true, timeoutMs: void 0, clamped: false };
|
|
1428
|
+
}
|
|
1429
|
+
if (typeof raw !== "number") {
|
|
1430
|
+
return {
|
|
1431
|
+
ok: false,
|
|
1432
|
+
reason: `timeoutMs must be a number of milliseconds, received ${typeof raw === "string" ? JSON.stringify(raw) : typeof raw}`
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
if (!Number.isFinite(raw)) {
|
|
1436
|
+
return {
|
|
1437
|
+
ok: false,
|
|
1438
|
+
reason: `timeoutMs must be a finite number of milliseconds, received ${String(raw)}`
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
if (raw < 0) {
|
|
1442
|
+
return {
|
|
1443
|
+
ok: false,
|
|
1444
|
+
reason: `timeoutMs must not be negative, received ${String(raw)}`
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
if (raw > MAX_ACTION_TIMEOUT_MS) {
|
|
1448
|
+
return { ok: true, timeoutMs: MAX_ACTION_TIMEOUT_MS, clamped: true };
|
|
1449
|
+
}
|
|
1450
|
+
return { ok: true, timeoutMs: Math.floor(raw), clamped: false };
|
|
1451
|
+
}
|
|
1452
|
+
function inertAbortSignal() {
|
|
1453
|
+
return new AbortController().signal;
|
|
1454
|
+
}
|
|
1455
|
+
async function runAbortable(invoke, options = {}) {
|
|
1456
|
+
const callerSignal = options.signal;
|
|
1457
|
+
const timeoutMs = options.timeoutMs;
|
|
1458
|
+
if (callerSignal?.aborted) {
|
|
1459
|
+
return { aborted: true, reason: "signal" };
|
|
1460
|
+
}
|
|
1461
|
+
const controller = new AbortController();
|
|
1462
|
+
let reason = "signal";
|
|
1463
|
+
let timer;
|
|
1464
|
+
const onCallerAbort = () => {
|
|
1465
|
+
reason = "signal";
|
|
1466
|
+
controller.abort();
|
|
1467
|
+
};
|
|
1468
|
+
callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
1469
|
+
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs >= 0) {
|
|
1470
|
+
timer = setTimeout(
|
|
1471
|
+
() => {
|
|
1472
|
+
reason = "timeout";
|
|
1473
|
+
controller.abort();
|
|
1474
|
+
},
|
|
1475
|
+
Math.min(timeoutMs, MAX_ACTION_TIMEOUT_MS)
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
try {
|
|
1479
|
+
const abandoned = new Promise((resolve) => {
|
|
1480
|
+
controller.signal.addEventListener("abort", () => resolve({ aborted: true, reason }), {
|
|
1481
|
+
once: true
|
|
1482
|
+
});
|
|
1483
|
+
});
|
|
1484
|
+
const completed = (async () => ({
|
|
1485
|
+
aborted: false,
|
|
1486
|
+
result: await invoke(controller.signal)
|
|
1487
|
+
}))();
|
|
1488
|
+
return await Promise.race([completed, abandoned]);
|
|
1489
|
+
} finally {
|
|
1490
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1491
|
+
callerSignal?.removeEventListener("abort", onCallerAbort);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// src/core/param-schema.ts
|
|
1496
|
+
var DEFAULT_PARAM_VALIDATION_MODE = "warn";
|
|
1497
|
+
var currentDefaultMode = DEFAULT_PARAM_VALIDATION_MODE;
|
|
1498
|
+
function getDefaultParamValidationMode() {
|
|
1499
|
+
return currentDefaultMode;
|
|
1500
|
+
}
|
|
1501
|
+
var PRIMITIVE_TYPE_NAMES = /* @__PURE__ */ new Set([
|
|
1502
|
+
"string",
|
|
1503
|
+
"number",
|
|
1504
|
+
"integer",
|
|
1505
|
+
"boolean",
|
|
1506
|
+
"object",
|
|
1507
|
+
"array",
|
|
1508
|
+
"null"
|
|
1509
|
+
]);
|
|
1510
|
+
var OBJECT_SCHEMA_KEYWORDS = /* @__PURE__ */ new Set([
|
|
1511
|
+
"type",
|
|
1512
|
+
"properties",
|
|
1513
|
+
"required",
|
|
1514
|
+
"additionalProperties",
|
|
1515
|
+
"patternProperties",
|
|
1516
|
+
"propertyNames",
|
|
1517
|
+
"minProperties",
|
|
1518
|
+
"maxProperties",
|
|
1519
|
+
"dependentRequired",
|
|
1520
|
+
"dependentSchemas",
|
|
1521
|
+
"unevaluatedProperties",
|
|
1522
|
+
"allOf",
|
|
1523
|
+
"anyOf",
|
|
1524
|
+
"oneOf",
|
|
1525
|
+
"not",
|
|
1526
|
+
"if",
|
|
1527
|
+
"then",
|
|
1528
|
+
"else",
|
|
1529
|
+
"enum",
|
|
1530
|
+
"const",
|
|
1531
|
+
"default",
|
|
1532
|
+
"examples",
|
|
1533
|
+
"title",
|
|
1534
|
+
"description",
|
|
1535
|
+
"deprecated",
|
|
1536
|
+
"readOnly",
|
|
1537
|
+
"writeOnly",
|
|
1538
|
+
"$schema",
|
|
1539
|
+
"$id",
|
|
1540
|
+
"$ref",
|
|
1541
|
+
"$comment",
|
|
1542
|
+
"$defs",
|
|
1543
|
+
"definitions"
|
|
1544
|
+
]);
|
|
1545
|
+
var MAX_SCHEMA_DEPTH = 32;
|
|
1546
|
+
var MAX_PATTERN_LENGTH = 200;
|
|
1547
|
+
function hasNestedQuantifiers(pattern) {
|
|
1548
|
+
return /(\((?:[^()]*[+*}])[^()]*\))[+*?]|\(\?:[^()]*[+*}][^()]*\)[+*?]/.test(pattern);
|
|
1549
|
+
}
|
|
1550
|
+
function isPlainObject(value) {
|
|
1551
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1552
|
+
}
|
|
1553
|
+
var hasOwnProperty = Object.prototype.hasOwnProperty;
|
|
1554
|
+
function hasOwn(value, key) {
|
|
1555
|
+
return hasOwnProperty.call(value, key);
|
|
1556
|
+
}
|
|
1557
|
+
function render(value) {
|
|
1558
|
+
if (value === null) return "null";
|
|
1559
|
+
if (value === void 0) return "undefined";
|
|
1560
|
+
if (Array.isArray(value)) return `array(${value.length})`;
|
|
1561
|
+
const t = typeof value;
|
|
1562
|
+
if (t === "string") return JSON.stringify(value);
|
|
1563
|
+
if (t === "number" || t === "boolean") return String(value);
|
|
1564
|
+
if (t === "object") return "object";
|
|
1565
|
+
return t;
|
|
1566
|
+
}
|
|
1567
|
+
var MAX_RENDERED_JSON_LENGTH = 120;
|
|
1568
|
+
function renderJson(value) {
|
|
1569
|
+
if (value === void 0) return "undefined";
|
|
1570
|
+
let text;
|
|
1571
|
+
try {
|
|
1572
|
+
text = JSON.stringify(value) ?? String(value);
|
|
1573
|
+
} catch {
|
|
1574
|
+
return render(value);
|
|
1575
|
+
}
|
|
1576
|
+
return text.length > MAX_RENDERED_JSON_LENGTH ? `${text.slice(0, MAX_RENDERED_JSON_LENGTH)}\u2026` : text;
|
|
1577
|
+
}
|
|
1578
|
+
function jsonTypeOf(value) {
|
|
1579
|
+
if (value === null) return "null";
|
|
1580
|
+
if (Array.isArray(value)) return "array";
|
|
1581
|
+
const t = typeof value;
|
|
1582
|
+
if (t === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
1583
|
+
if (t === "string" || t === "boolean" || t === "object") return t;
|
|
1584
|
+
return t;
|
|
1585
|
+
}
|
|
1586
|
+
function matchesType(value, typeName) {
|
|
1587
|
+
switch (typeName) {
|
|
1588
|
+
case "string":
|
|
1589
|
+
return typeof value === "string";
|
|
1590
|
+
case "number":
|
|
1591
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
1592
|
+
case "integer":
|
|
1593
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
1594
|
+
case "boolean":
|
|
1595
|
+
return typeof value === "boolean";
|
|
1596
|
+
case "null":
|
|
1597
|
+
return value === null;
|
|
1598
|
+
case "array":
|
|
1599
|
+
return Array.isArray(value);
|
|
1600
|
+
case "object":
|
|
1601
|
+
return isPlainObject(value);
|
|
1602
|
+
default:
|
|
1603
|
+
return true;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
function jsonEquals(a, b, depth = 0) {
|
|
1607
|
+
if (a === b) return true;
|
|
1608
|
+
if (depth > MAX_SCHEMA_DEPTH) return false;
|
|
1609
|
+
if (a === null || b === null || a === void 0 || b === void 0) return false;
|
|
1610
|
+
if (typeof a !== typeof b) return false;
|
|
1611
|
+
if (typeof a !== "object") return false;
|
|
1612
|
+
const aIsArray = Array.isArray(a);
|
|
1613
|
+
if (aIsArray !== Array.isArray(b)) return false;
|
|
1614
|
+
if (aIsArray) {
|
|
1615
|
+
const bArr = b;
|
|
1616
|
+
const aArr = a;
|
|
1617
|
+
if (aArr.length !== bArr.length) return false;
|
|
1618
|
+
return aArr.every((item, i) => jsonEquals(item, bArr[i], depth + 1));
|
|
1619
|
+
}
|
|
1620
|
+
const aObj = a;
|
|
1621
|
+
const bObj = b;
|
|
1622
|
+
const aKeys = Object.keys(aObj);
|
|
1623
|
+
const bKeys = Object.keys(bObj);
|
|
1624
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
1625
|
+
return aKeys.every((key) => hasOwn(bObj, key) && jsonEquals(aObj[key], bObj[key], depth + 1));
|
|
1626
|
+
}
|
|
1627
|
+
function joinPath(base, key) {
|
|
1628
|
+
return base === "" ? key : `${base}.${key}`;
|
|
1629
|
+
}
|
|
1630
|
+
function readTypes(schema) {
|
|
1631
|
+
const raw = schema["type"];
|
|
1632
|
+
if (typeof raw === "string") return [raw];
|
|
1633
|
+
if (Array.isArray(raw)) {
|
|
1634
|
+
const names = raw.filter((t) => typeof t === "string");
|
|
1635
|
+
return names.length > 0 ? names : null;
|
|
1636
|
+
}
|
|
1637
|
+
return null;
|
|
1638
|
+
}
|
|
1639
|
+
function validateValue(value, schema, path, issues, depth) {
|
|
1640
|
+
if (depth > MAX_SCHEMA_DEPTH) return;
|
|
1641
|
+
if (typeof schema === "string") {
|
|
1642
|
+
if (PRIMITIVE_TYPE_NAMES.has(schema) && !matchesType(value, schema)) {
|
|
1643
|
+
issues.push({
|
|
1644
|
+
path,
|
|
1645
|
+
keyword: "type",
|
|
1646
|
+
expected: schema,
|
|
1647
|
+
received: render(value),
|
|
1648
|
+
message: `Parameter "${path}" must be of type ${schema}, received ${jsonTypeOf(value)} (${render(value)}).`
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
if (!isPlainObject(schema)) return;
|
|
1654
|
+
const types = readTypes(schema);
|
|
1655
|
+
if (types !== null && !types.some((t) => matchesType(value, t))) {
|
|
1656
|
+
const expected = types.join(" | ");
|
|
1657
|
+
issues.push({
|
|
1658
|
+
path,
|
|
1659
|
+
keyword: "type",
|
|
1660
|
+
expected,
|
|
1661
|
+
received: render(value),
|
|
1662
|
+
message: `Parameter "${path}" must be of type ${expected}, received ${jsonTypeOf(value)} (${render(value)}).`
|
|
1663
|
+
});
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
if (hasOwn(schema, "const") && !jsonEquals(value, schema["const"])) {
|
|
1667
|
+
const expected = renderJson(schema["const"]);
|
|
1668
|
+
const received = renderJson(value);
|
|
1669
|
+
issues.push({
|
|
1670
|
+
path,
|
|
1671
|
+
keyword: "const",
|
|
1672
|
+
expected,
|
|
1673
|
+
received,
|
|
1674
|
+
message: `Parameter "${path}" must equal ${expected}, received ${received}.`
|
|
1675
|
+
});
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
const enumValues = schema["enum"];
|
|
1679
|
+
if (Array.isArray(enumValues) && !enumValues.some((allowed) => jsonEquals(value, allowed))) {
|
|
1680
|
+
const expected = enumValues.map((v) => renderJson(v)).join(", ");
|
|
1681
|
+
const received = renderJson(value);
|
|
1682
|
+
issues.push({
|
|
1683
|
+
path,
|
|
1684
|
+
keyword: "enum",
|
|
1685
|
+
expected,
|
|
1686
|
+
received,
|
|
1687
|
+
message: `Parameter "${path}" must be one of [${expected}], received ${received}.`
|
|
1688
|
+
});
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
if (typeof value === "number") {
|
|
1692
|
+
const min = schema["minimum"];
|
|
1693
|
+
if (typeof min === "number" && value < min) {
|
|
1694
|
+
issues.push({
|
|
1695
|
+
path,
|
|
1696
|
+
keyword: "minimum",
|
|
1697
|
+
expected: `>= ${min}`,
|
|
1698
|
+
received: render(value),
|
|
1699
|
+
message: `Parameter "${path}" must be >= ${min}, received ${value}.`
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
const max = schema["maximum"];
|
|
1703
|
+
if (typeof max === "number" && value > max) {
|
|
1704
|
+
issues.push({
|
|
1705
|
+
path,
|
|
1706
|
+
keyword: "maximum",
|
|
1707
|
+
expected: `<= ${max}`,
|
|
1708
|
+
received: render(value),
|
|
1709
|
+
message: `Parameter "${path}" must be <= ${max}, received ${value}.`
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
if (typeof value === "string") {
|
|
1714
|
+
const minLength = schema["minLength"];
|
|
1715
|
+
if (typeof minLength === "number" && value.length < minLength) {
|
|
1716
|
+
issues.push({
|
|
1717
|
+
path,
|
|
1718
|
+
keyword: "minLength",
|
|
1719
|
+
expected: `length >= ${minLength}`,
|
|
1720
|
+
received: `length ${value.length}`,
|
|
1721
|
+
message: `Parameter "${path}" must be at least ${minLength} characters, received ${value.length}.`
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
const maxLength = schema["maxLength"];
|
|
1725
|
+
if (typeof maxLength === "number" && value.length > maxLength) {
|
|
1726
|
+
issues.push({
|
|
1727
|
+
path,
|
|
1728
|
+
keyword: "maxLength",
|
|
1729
|
+
expected: `length <= ${maxLength}`,
|
|
1730
|
+
received: `length ${value.length}`,
|
|
1731
|
+
message: `Parameter "${path}" must be at most ${maxLength} characters, received ${value.length}.`
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
const pattern = schema["pattern"];
|
|
1735
|
+
if (typeof pattern === "string" && pattern.length <= MAX_PATTERN_LENGTH && !hasNestedQuantifiers(pattern)) {
|
|
1736
|
+
let re;
|
|
1737
|
+
try {
|
|
1738
|
+
re = new RegExp(pattern);
|
|
1739
|
+
} catch {
|
|
1740
|
+
re = null;
|
|
1741
|
+
}
|
|
1742
|
+
if (re !== null && !re.test(value)) {
|
|
1743
|
+
issues.push({
|
|
1744
|
+
path,
|
|
1745
|
+
keyword: "pattern",
|
|
1746
|
+
expected: `match /${pattern}/`,
|
|
1747
|
+
received: render(value),
|
|
1748
|
+
message: `Parameter "${path}" must match /${pattern}/, received ${render(value)}.`
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
if (Array.isArray(value) && hasOwn(schema, "items")) {
|
|
1754
|
+
const items = schema["items"];
|
|
1755
|
+
value.forEach((element, index) => {
|
|
1756
|
+
validateValue(element, items, `${path}[${index}]`, issues, depth + 1);
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
if (isPlainObject(value) && (isPlainObject(schema["properties"]) || hasOwn(schema, "required") || schema["additionalProperties"] === false)) {
|
|
1760
|
+
validateObject(value, schema, path, issues, depth);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
function validateObject(value, schema, basePath, issues, depth) {
|
|
1764
|
+
if (depth > MAX_SCHEMA_DEPTH) return;
|
|
1765
|
+
const properties = isPlainObject(schema["properties"]) ? schema["properties"] : {};
|
|
1766
|
+
const requiredRaw = schema["required"];
|
|
1767
|
+
const required = Array.isArray(requiredRaw) ? requiredRaw.filter((k) => typeof k === "string") : [];
|
|
1768
|
+
for (const key of required) {
|
|
1769
|
+
if (!hasOwn(value, key) || value[key] === void 0) {
|
|
1770
|
+
const at = joinPath(basePath, key);
|
|
1771
|
+
issues.push({
|
|
1772
|
+
path: at,
|
|
1773
|
+
keyword: "required",
|
|
1774
|
+
expected: "present",
|
|
1775
|
+
message: `Required parameter "${at}" is missing.`
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
if (schema["additionalProperties"] === false) {
|
|
1780
|
+
const declared = new Set(Object.keys(properties));
|
|
1781
|
+
const expected = declared.size === 0 ? "no parameters" : `one of [${[...declared].join(", ")}]`;
|
|
1782
|
+
for (const key of Object.keys(value)) {
|
|
1783
|
+
if (!declared.has(key)) {
|
|
1784
|
+
const at = joinPath(basePath, key);
|
|
1785
|
+
issues.push({
|
|
1786
|
+
path: at,
|
|
1787
|
+
keyword: "additionalProperties",
|
|
1788
|
+
expected,
|
|
1789
|
+
received: render(value[key]),
|
|
1790
|
+
message: `Parameter "${at}" is not declared by the schema, which sets additionalProperties: false.`
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
for (const [key, propSchema] of Object.entries(properties)) {
|
|
1796
|
+
if (!hasOwn(value, key) || value[key] === void 0) continue;
|
|
1797
|
+
validateValue(value[key], propSchema, joinPath(basePath, key), issues, depth + 1);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
function isObjectSchemaForm(schema) {
|
|
1801
|
+
if (isPlainObject(schema["properties"])) return true;
|
|
1802
|
+
if (schema["type"] !== "object") return false;
|
|
1803
|
+
return Object.keys(schema).every((key) => OBJECT_SCHEMA_KEYWORDS.has(key));
|
|
1804
|
+
}
|
|
1805
|
+
function normalizeMapForm(schema) {
|
|
1806
|
+
if (isObjectSchemaForm(schema)) return null;
|
|
1807
|
+
const properties = {};
|
|
1808
|
+
for (const [key, raw] of Object.entries(schema)) {
|
|
1809
|
+
if (raw === null || raw === void 0) continue;
|
|
1810
|
+
if (typeof raw === "string") {
|
|
1811
|
+
properties[key] = PRIMITIVE_TYPE_NAMES.has(raw) ? { type: raw } : {};
|
|
1812
|
+
continue;
|
|
1813
|
+
}
|
|
1814
|
+
if (isPlainObject(raw)) {
|
|
1815
|
+
const { optional: _optional, ...rest } = raw;
|
|
1816
|
+
properties[key] = rest;
|
|
1817
|
+
continue;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
return { type: "object", properties };
|
|
1821
|
+
}
|
|
1822
|
+
function validateActionParams(schema, params) {
|
|
1823
|
+
if (!isPlainObject(schema)) return { valid: true, issues: [] };
|
|
1824
|
+
const normalized = normalizeMapForm(schema) ?? schema;
|
|
1825
|
+
const hasProperties = isPlainObject(normalized["properties"]);
|
|
1826
|
+
const hasRequired = Array.isArray(normalized["required"]);
|
|
1827
|
+
const forbidsExtras = normalized["additionalProperties"] === false;
|
|
1828
|
+
if (!hasProperties && !hasRequired && !forbidsExtras) {
|
|
1829
|
+
return { valid: true, issues: [] };
|
|
1830
|
+
}
|
|
1831
|
+
const issues = [];
|
|
1832
|
+
if (params === void 0 || params === null) {
|
|
1833
|
+
validateObject({}, normalized, "", issues, 0);
|
|
1834
|
+
return { valid: issues.length === 0, issues };
|
|
1835
|
+
}
|
|
1836
|
+
if (!isPlainObject(params)) {
|
|
1837
|
+
issues.push({
|
|
1838
|
+
path: "",
|
|
1839
|
+
keyword: "type",
|
|
1840
|
+
expected: "object",
|
|
1841
|
+
received: render(params),
|
|
1842
|
+
message: `Action params must be an object, received ${jsonTypeOf(params)} (${render(params)}).`
|
|
1843
|
+
});
|
|
1844
|
+
return { valid: false, issues };
|
|
1845
|
+
}
|
|
1846
|
+
validateObject(params, normalized, "", issues, 0);
|
|
1847
|
+
return { valid: issues.length === 0, issues };
|
|
1848
|
+
}
|
|
1849
|
+
function formatParamValidationFailure(componentId, actionId, issues) {
|
|
1850
|
+
const detail = issues.map((i) => i.message).join(" ");
|
|
1851
|
+
return `Action "${actionId}" on component "${componentId}" was rejected: params do not match the action's declared paramSchema. ${detail}`;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1066
1854
|
// src/control/action-executor.ts
|
|
1067
1855
|
var DEFAULT_WAIT_OPTIONS = {
|
|
1068
1856
|
visible: true,
|
|
@@ -1072,6 +1860,68 @@ var DEFAULT_WAIT_OPTIONS = {
|
|
|
1072
1860
|
timeout: 1e4,
|
|
1073
1861
|
interval: 100
|
|
1074
1862
|
};
|
|
1863
|
+
var NativeActionError = class extends Error {
|
|
1864
|
+
constructor(message, code) {
|
|
1865
|
+
super(message);
|
|
1866
|
+
this.code = code;
|
|
1867
|
+
this.name = "NativeActionError";
|
|
1868
|
+
}
|
|
1869
|
+
};
|
|
1870
|
+
var DEFAULT_SCROLL_INTO_VIEW_PADDING = 16;
|
|
1871
|
+
var SCROLL_INTO_VIEW_MEASURE_TIMEOUT_MS = 1e3;
|
|
1872
|
+
var SCROLL_INTO_VIEW_WINDOW_MEASURE_TIMEOUT_MS = 250;
|
|
1873
|
+
function measurePageRectNow(element, preferred) {
|
|
1874
|
+
const preferredNode = preferred;
|
|
1875
|
+
const node = preferredNode && typeof preferredNode.measureInWindow === "function" ? preferredNode : element.ref.current;
|
|
1876
|
+
if (!node || typeof node.measureInWindow !== "function") {
|
|
1877
|
+
const state = element.getState();
|
|
1878
|
+
return Promise.resolve(state.visible ? pageRectOf(state) : null);
|
|
1879
|
+
}
|
|
1880
|
+
return new Promise((resolve) => {
|
|
1881
|
+
let settled = false;
|
|
1882
|
+
const done = (rect) => {
|
|
1883
|
+
if (settled) return;
|
|
1884
|
+
settled = true;
|
|
1885
|
+
clearTimeout(timer);
|
|
1886
|
+
resolve(rect);
|
|
1887
|
+
};
|
|
1888
|
+
const timer = setTimeout(() => done(null), SCROLL_INTO_VIEW_WINDOW_MEASURE_TIMEOUT_MS);
|
|
1889
|
+
try {
|
|
1890
|
+
node.measureInWindow(
|
|
1891
|
+
(x, y, w, h) => done(
|
|
1892
|
+
[x, y, w, h].every(Number.isFinite) && w > 0 && h > 0 ? { left: x, top: y, right: x + w, bottom: y + h } : null
|
|
1893
|
+
)
|
|
1894
|
+
);
|
|
1895
|
+
} catch {
|
|
1896
|
+
done(null);
|
|
1897
|
+
}
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
function resolveScrollView(current) {
|
|
1901
|
+
const c = current;
|
|
1902
|
+
if (!c) return null;
|
|
1903
|
+
if (typeof c.scrollTo === "function")
|
|
1904
|
+
return c;
|
|
1905
|
+
for (const unwrap of [c.getNativeScrollRef, c.getScrollResponder]) {
|
|
1906
|
+
if (typeof unwrap !== "function") continue;
|
|
1907
|
+
let inner;
|
|
1908
|
+
try {
|
|
1909
|
+
inner = unwrap.call(c);
|
|
1910
|
+
} catch {
|
|
1911
|
+
inner = null;
|
|
1912
|
+
}
|
|
1913
|
+
if (inner && typeof inner.scrollTo === "function")
|
|
1914
|
+
return inner;
|
|
1915
|
+
}
|
|
1916
|
+
return null;
|
|
1917
|
+
}
|
|
1918
|
+
function isDeclaredHorizontal(registeredProps, ...refs) {
|
|
1919
|
+
if (registeredProps?.horizontal === true) return true;
|
|
1920
|
+
return refs.some((r) => {
|
|
1921
|
+
const props = r?.props;
|
|
1922
|
+
return props !== null && typeof props === "object" && props.horizontal === true;
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1075
1925
|
function sleep(ms) {
|
|
1076
1926
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1077
1927
|
}
|
|
@@ -1191,6 +2041,7 @@ var DefaultNativeActionExecutor = class {
|
|
|
1191
2041
|
return {
|
|
1192
2042
|
success: false,
|
|
1193
2043
|
error: errorMsg,
|
|
2044
|
+
...error3 instanceof NativeActionError ? { code: error3.code } : {},
|
|
1194
2045
|
stack: error3 instanceof Error ? error3.stack : void 0,
|
|
1195
2046
|
durationMs: failTime - startTime,
|
|
1196
2047
|
timestamp: failTime,
|
|
@@ -1208,7 +2059,7 @@ var DefaultNativeActionExecutor = class {
|
|
|
1208
2059
|
}
|
|
1209
2060
|
const props = element.props || {};
|
|
1210
2061
|
if (element.customActions && action in element.customActions) {
|
|
1211
|
-
return element.customActions[action].handler(params);
|
|
2062
|
+
return element.customActions[action].handler(params, { signal: inertAbortSignal() });
|
|
1212
2063
|
}
|
|
1213
2064
|
switch (action) {
|
|
1214
2065
|
case "click":
|
|
@@ -1231,6 +2082,11 @@ var DefaultNativeActionExecutor = class {
|
|
|
1231
2082
|
return this.performBlur(element);
|
|
1232
2083
|
case "scroll":
|
|
1233
2084
|
return this.performScroll(props, params);
|
|
2085
|
+
case "scrollIntoView":
|
|
2086
|
+
return this.performScrollIntoView(
|
|
2087
|
+
element,
|
|
2088
|
+
params
|
|
2089
|
+
);
|
|
1234
2090
|
case "swipe":
|
|
1235
2091
|
return this.performSwipe(props, params);
|
|
1236
2092
|
case "toggle":
|
|
@@ -1397,6 +2253,138 @@ var DefaultNativeActionExecutor = class {
|
|
|
1397
2253
|
props.onScroll(event);
|
|
1398
2254
|
}
|
|
1399
2255
|
}
|
|
2256
|
+
/**
|
|
2257
|
+
* Perform scrollIntoView: scroll the element's DECLARED scroll container so
|
|
2258
|
+
* the element sits `padding` dp below the top of its viewport.
|
|
2259
|
+
*
|
|
2260
|
+
* The container comes from `scrollAncestorId` and nowhere else — the
|
|
2261
|
+
* registry keeps no parent chain (`RegisterElementOptions.scrollAncestorId`),
|
|
2262
|
+
* so there is no "nearest ScrollView" to find. Nothing here tracks a
|
|
2263
|
+
* ScrollView's content offset either (`performScroll` only fakes an
|
|
2264
|
+
* `onScroll` event), so the target is measured CONTENT-relative with
|
|
2265
|
+
* `measureLayout` against the container's inner content view: stateless,
|
|
2266
|
+
* and immune to a stale-offset race.
|
|
2267
|
+
*
|
|
2268
|
+
* Result shape mirrors the web executor's `scrollIntoView`
|
|
2269
|
+
* (`@qontinui/ui-bridge` `control/action-executor.ts`):
|
|
2270
|
+
* `{ alreadyVisible, scrolled }`, short-circuiting when the element is
|
|
2271
|
+
* already fully inside its clip region. "Fully visible" is only claimed when
|
|
2272
|
+
* it is MEASURED — a page-space rect for the element and a known,
|
|
2273
|
+
* non-empty clip (window ∩ declared container), both measured at call
|
|
2274
|
+
* time. Anything unknown scrolls.
|
|
2275
|
+
*
|
|
2276
|
+
* Containers: a ScrollView, or a VirtualizedList (FlatList / SectionList)
|
|
2277
|
+
* unwrapped through `getNativeScrollRef()` / `getScrollResponder()`.
|
|
2278
|
+
* Vertical only — a `horizontal` container is NOT_SUPPORTED. The action
|
|
2279
|
+
* returns once `scrollTo` is dispatched; positions settle on the next frame.
|
|
2280
|
+
*
|
|
2281
|
+
* Prerequisites are checked BEFORE the visibility short-circuit, so whether
|
|
2282
|
+
* the element supports the action does not depend on where it happens to be
|
|
2283
|
+
* on screen. Each missing prerequisite is a `NOT_SUPPORTED` failure.
|
|
2284
|
+
*/
|
|
2285
|
+
async performScrollIntoView(element, params) {
|
|
2286
|
+
const padding = params?.padding ?? DEFAULT_SCROLL_INTO_VIEW_PADDING;
|
|
2287
|
+
if (typeof padding !== "number" || !Number.isFinite(padding) || padding < 0) {
|
|
2288
|
+
throw new Error('scrollIntoView "padding" must be a finite, non-negative number');
|
|
2289
|
+
}
|
|
2290
|
+
const ancestorId = element.scrollAncestorId;
|
|
2291
|
+
if (!ancestorId) {
|
|
2292
|
+
throw new NativeActionError(
|
|
2293
|
+
`scrollIntoView is not supported on element "${element.id}": it declares no scrollAncestorId, and the registry keeps no parent chain to find its scroll container from`,
|
|
2294
|
+
"NOT_SUPPORTED"
|
|
2295
|
+
);
|
|
2296
|
+
}
|
|
2297
|
+
const ancestor = ancestorId === element.id ? void 0 : this.registry.getElement(ancestorId);
|
|
2298
|
+
if (!ancestor) {
|
|
2299
|
+
throw new NativeActionError(
|
|
2300
|
+
`scrollIntoView is not supported on element "${element.id}": its declared scrollAncestorId "${ancestorId}" is not a registered element`,
|
|
2301
|
+
"NOT_SUPPORTED"
|
|
2302
|
+
);
|
|
2303
|
+
}
|
|
2304
|
+
const container = resolveScrollView(ancestor.ref.current);
|
|
2305
|
+
if (!container) {
|
|
2306
|
+
throw new NativeActionError(
|
|
2307
|
+
`scrollIntoView is not supported on element "${element.id}": its scroll ancestor "${ancestorId}" has no scrollTo (not a mounted ScrollView, and no getNativeScrollRef/getScrollResponder yielding one)`,
|
|
2308
|
+
"NOT_SUPPORTED"
|
|
2309
|
+
);
|
|
2310
|
+
}
|
|
2311
|
+
if (isDeclaredHorizontal(ancestor.props, ancestor.ref.current, container)) {
|
|
2312
|
+
throw new NativeActionError(
|
|
2313
|
+
`scrollIntoView is not supported on element "${element.id}": its scroll ancestor "${ancestorId}" is horizontal (only vertical containers are supported)`,
|
|
2314
|
+
"NOT_SUPPORTED"
|
|
2315
|
+
);
|
|
2316
|
+
}
|
|
2317
|
+
const innerViewRef = container.getInnerViewRef?.();
|
|
2318
|
+
const innerView = innerViewRef ?? container.getInnerViewNode?.();
|
|
2319
|
+
const innerViewIsTag = innerViewRef == null && typeof innerView === "number";
|
|
2320
|
+
if (innerView === void 0 || innerView === null) {
|
|
2321
|
+
throw new NativeActionError(
|
|
2322
|
+
`scrollIntoView is not supported on element "${element.id}": its scroll ancestor "${ancestorId}" exposes no inner content view to measure against`,
|
|
2323
|
+
"NOT_SUPPORTED"
|
|
2324
|
+
);
|
|
2325
|
+
}
|
|
2326
|
+
const target = element.ref.current;
|
|
2327
|
+
if (!target || typeof target.measureLayout !== "function") {
|
|
2328
|
+
throw new NativeActionError(
|
|
2329
|
+
`scrollIntoView is not supported on element "${element.id}": its ref has no measureLayout (not a mounted host view)`,
|
|
2330
|
+
"NOT_SUPPORTED"
|
|
2331
|
+
);
|
|
2332
|
+
}
|
|
2333
|
+
const [rect, ancestorRect] = await Promise.all([
|
|
2334
|
+
measurePageRectNow(element),
|
|
2335
|
+
measurePageRectNow(ancestor, container)
|
|
2336
|
+
]);
|
|
2337
|
+
const viewport = this.registry.getViewportRect();
|
|
2338
|
+
const clip = ancestorRect ? intersectRects(viewport, ancestorRect) : null;
|
|
2339
|
+
if (rect && clip && !isEmptyRect(clip) && rect.right > rect.left && rect.bottom > rect.top && rect.top >= clip.top && rect.left >= clip.left && rect.bottom <= clip.bottom && rect.right <= clip.right) {
|
|
2340
|
+
return { alreadyVisible: true, scrolled: false };
|
|
2341
|
+
}
|
|
2342
|
+
const measured = await new Promise(
|
|
2343
|
+
(resolve, reject) => {
|
|
2344
|
+
let settled = false;
|
|
2345
|
+
const timer = setTimeout(() => {
|
|
2346
|
+
if (settled) return;
|
|
2347
|
+
settled = true;
|
|
2348
|
+
reject(
|
|
2349
|
+
new Error(
|
|
2350
|
+
`scrollIntoView: measureLayout did not call back within ${SCROLL_INTO_VIEW_MEASURE_TIMEOUT_MS}ms` + (innerViewIsTag ? " (measured against a numeric inner-view tag from getInnerViewNode, which the new architecture does not support)" : "")
|
|
2351
|
+
)
|
|
2352
|
+
);
|
|
2353
|
+
}, SCROLL_INTO_VIEW_MEASURE_TIMEOUT_MS);
|
|
2354
|
+
const finish = (fn) => {
|
|
2355
|
+
if (settled) return;
|
|
2356
|
+
settled = true;
|
|
2357
|
+
clearTimeout(timer);
|
|
2358
|
+
fn();
|
|
2359
|
+
};
|
|
2360
|
+
try {
|
|
2361
|
+
target.measureLayout(
|
|
2362
|
+
innerView,
|
|
2363
|
+
(x, y, width) => finish(
|
|
2364
|
+
() => Number.isFinite(y) ? resolve({ x, y, width }) : reject(new Error("scrollIntoView: measureLayout returned a non-finite y"))
|
|
2365
|
+
),
|
|
2366
|
+
() => finish(
|
|
2367
|
+
() => reject(
|
|
2368
|
+
new Error(
|
|
2369
|
+
`scrollIntoView: measureLayout failed for "${element.id}" relative to "${ancestorId}"`
|
|
2370
|
+
)
|
|
2371
|
+
)
|
|
2372
|
+
)
|
|
2373
|
+
);
|
|
2374
|
+
} catch (error3) {
|
|
2375
|
+
finish(() => reject(error3));
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
);
|
|
2379
|
+
if (ancestorRect && Number.isFinite(measured.x) && Number.isFinite(measured.width) && measured.x + measured.width > ancestorRect.right - ancestorRect.left + 1) {
|
|
2380
|
+
throw new NativeActionError(
|
|
2381
|
+
`scrollIntoView is not supported on element "${element.id}": it is laid out beyond the horizontal extent of its scroll ancestor "${ancestorId}" (sideways overflow, a horizontal container, or a nested horizontal container \u2014 declare that inner container as its scrollAncestorId); only vertical scrolling is supported`,
|
|
2382
|
+
"NOT_SUPPORTED"
|
|
2383
|
+
);
|
|
2384
|
+
}
|
|
2385
|
+
container.scrollTo({ y: Math.max(0, measured.y - padding), animated: false });
|
|
2386
|
+
return { alreadyVisible: false, scrolled: true };
|
|
2387
|
+
}
|
|
1400
2388
|
/**
|
|
1401
2389
|
* Perform swipe action
|
|
1402
2390
|
*/
|
|
@@ -1451,7 +2439,7 @@ var DefaultNativeActionExecutor = class {
|
|
|
1451
2439
|
/**
|
|
1452
2440
|
* Execute a component action
|
|
1453
2441
|
*/
|
|
1454
|
-
async executeComponentAction(componentId, request) {
|
|
2442
|
+
async executeComponentAction(componentId, request, options = {}) {
|
|
1455
2443
|
const startTime = Date.now();
|
|
1456
2444
|
try {
|
|
1457
2445
|
const component = this.registry.getComponent(componentId);
|
|
@@ -1474,10 +2462,71 @@ var DefaultNativeActionExecutor = class {
|
|
|
1474
2462
|
requestId: request.requestId
|
|
1475
2463
|
};
|
|
1476
2464
|
}
|
|
1477
|
-
const
|
|
2465
|
+
const timeout = normalizeActionTimeoutMs(request.timeoutMs);
|
|
2466
|
+
if (!timeout.ok) {
|
|
2467
|
+
return {
|
|
2468
|
+
success: false,
|
|
2469
|
+
error: `Action "${request.action}" on component "${componentId}" was rejected: ${timeout.reason}.`,
|
|
2470
|
+
durationMs: Date.now() - startTime,
|
|
2471
|
+
timestamp: Date.now(),
|
|
2472
|
+
requestId: request.requestId
|
|
2473
|
+
};
|
|
2474
|
+
}
|
|
2475
|
+
const timeoutMs = timeout.timeoutMs;
|
|
2476
|
+
const validationMode = options.paramValidation ?? getDefaultParamValidationMode();
|
|
2477
|
+
if (validationMode !== "off" && action.paramSchema !== void 0) {
|
|
2478
|
+
let validation;
|
|
2479
|
+
try {
|
|
2480
|
+
validation = validateActionParams(action.paramSchema, request.params);
|
|
2481
|
+
} catch (validatorFault) {
|
|
2482
|
+
const detail = validatorFault instanceof Error ? validatorFault.message : String(validatorFault);
|
|
2483
|
+
const faultMessage = `Action "${request.action}" on component "${componentId}": its declared paramSchema could not be evaluated (${detail}).`;
|
|
2484
|
+
if (validationMode === "enforce") {
|
|
2485
|
+
return {
|
|
2486
|
+
success: false,
|
|
2487
|
+
error: faultMessage,
|
|
2488
|
+
durationMs: Date.now() - startTime,
|
|
2489
|
+
timestamp: Date.now(),
|
|
2490
|
+
requestId: request.requestId
|
|
2491
|
+
};
|
|
2492
|
+
}
|
|
2493
|
+
console.warn(`[ui-bridge-native] ${faultMessage}`);
|
|
2494
|
+
validation = { valid: true, issues: [] };
|
|
2495
|
+
}
|
|
2496
|
+
if (!validation.valid) {
|
|
2497
|
+
const message = formatParamValidationFailure(
|
|
2498
|
+
componentId,
|
|
2499
|
+
request.action,
|
|
2500
|
+
validation.issues
|
|
2501
|
+
);
|
|
2502
|
+
if (validationMode === "enforce") {
|
|
2503
|
+
return {
|
|
2504
|
+
success: false,
|
|
2505
|
+
error: message,
|
|
2506
|
+
durationMs: Date.now() - startTime,
|
|
2507
|
+
timestamp: Date.now(),
|
|
2508
|
+
requestId: request.requestId
|
|
2509
|
+
};
|
|
2510
|
+
}
|
|
2511
|
+
console.warn(`[ui-bridge-native] ${message}`);
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
const outcome = await runAbortable((signal) => action.handler(request.params, { signal }), {
|
|
2515
|
+
signal: options.signal,
|
|
2516
|
+
timeoutMs
|
|
2517
|
+
});
|
|
2518
|
+
if (outcome.aborted) {
|
|
2519
|
+
return {
|
|
2520
|
+
success: false,
|
|
2521
|
+
error: outcome.reason === "timeout" ? `Action "${request.action}" on component "${componentId}" was abandoned after its ${timeoutMs}ms timeout elapsed.` : `Action "${request.action}" on component "${componentId}" was cancelled by the caller's abort signal.`,
|
|
2522
|
+
durationMs: Date.now() - startTime,
|
|
2523
|
+
timestamp: Date.now(),
|
|
2524
|
+
requestId: request.requestId
|
|
2525
|
+
};
|
|
2526
|
+
}
|
|
1478
2527
|
return {
|
|
1479
2528
|
success: true,
|
|
1480
|
-
result,
|
|
2529
|
+
result: outcome.result,
|
|
1481
2530
|
durationMs: Date.now() - startTime,
|
|
1482
2531
|
timestamp: Date.now(),
|
|
1483
2532
|
requestId: request.requestId
|
|
@@ -2544,16 +3593,21 @@ function createServerHandlers(registry, executor, config) {
|
|
|
2544
3593
|
return {
|
|
2545
3594
|
// Elements
|
|
2546
3595
|
getElements: async (ctx) => {
|
|
3596
|
+
await registry.refreshMeasurements();
|
|
2547
3597
|
const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
|
|
2548
3598
|
const forRoute = ctx.query?.route || ctx.body?.route;
|
|
2549
3599
|
let allElements = visibleOnly ? registry.getMountedVisibleElements() : registry.getAllElements();
|
|
2550
3600
|
if (forRoute) {
|
|
2551
3601
|
allElements = allElements.filter((e) => matchesCurrentRoute(e.registrationRoute, forRoute));
|
|
2552
3602
|
}
|
|
3603
|
+
const viewport = registry.getViewportRect();
|
|
2553
3604
|
const elements = allElements.map((e) => {
|
|
2554
3605
|
const handlers = extractHandlerNames(e.props);
|
|
2555
3606
|
const state = e.getState();
|
|
2556
|
-
const visibility
|
|
3607
|
+
const { visibility, visibilityReason } = computeVisibility(
|
|
3608
|
+
state,
|
|
3609
|
+
registry.getClipRectFor(e, viewport)
|
|
3610
|
+
);
|
|
2557
3611
|
const bbox = projectBbox(state, visibility, registry.getPixelRatio());
|
|
2558
3612
|
const vision = projectVisionFields({
|
|
2559
3613
|
type: e.type,
|
|
@@ -2571,10 +3625,11 @@ function createServerHandlers(registry, executor, config) {
|
|
|
2571
3625
|
identifier: e.getIdentifier(),
|
|
2572
3626
|
state,
|
|
2573
3627
|
actions: e.actions,
|
|
2574
|
-
customActions:
|
|
3628
|
+
customActions: serializeElementCustomActions(e.customActions),
|
|
2575
3629
|
registeredHandlers: handlers.length > 0 ? handlers : void 0,
|
|
2576
3630
|
registrationRoute: e.registrationRoute,
|
|
2577
3631
|
visibility,
|
|
3632
|
+
...visibilityReason ? { visibilityReason } : {},
|
|
2578
3633
|
...bbox ? { bbox } : {},
|
|
2579
3634
|
...vision
|
|
2580
3635
|
};
|
|
@@ -2596,7 +3651,7 @@ function createServerHandlers(registry, executor, config) {
|
|
|
2596
3651
|
identifier: element.getIdentifier(),
|
|
2597
3652
|
state: resolveElementState(element),
|
|
2598
3653
|
actions: element.actions,
|
|
2599
|
-
customActions:
|
|
3654
|
+
customActions: serializeElementCustomActions(element.customActions),
|
|
2600
3655
|
registeredHandlers: handlers.length > 0 ? handlers : void 0,
|
|
2601
3656
|
registrationRoute: element.registrationRoute
|
|
2602
3657
|
}
|
|
@@ -2617,13 +3672,9 @@ function createServerHandlers(registry, executor, config) {
|
|
|
2617
3672
|
if (!envelope?.action) {
|
|
2618
3673
|
return error2("Action is required", "INVALID_REQUEST");
|
|
2619
3674
|
}
|
|
2620
|
-
const response = await executor.executeAction(id,
|
|
2621
|
-
action: envelope.action,
|
|
2622
|
-
params: envelope.params,
|
|
2623
|
-
waitOptions: envelope.waitOptions
|
|
2624
|
-
});
|
|
3675
|
+
const response = await executor.executeAction(id, envelope);
|
|
2625
3676
|
if (!response.success) {
|
|
2626
|
-
return error2(response.error || "Action failed", "ACTION_FAILED");
|
|
3677
|
+
return error2(response.error || "Action failed", response.code ?? "ACTION_FAILED");
|
|
2627
3678
|
}
|
|
2628
3679
|
return success2(response);
|
|
2629
3680
|
},
|
|
@@ -2633,7 +3684,11 @@ function createServerHandlers(registry, executor, config) {
|
|
|
2633
3684
|
id: c.id,
|
|
2634
3685
|
name: c.name,
|
|
2635
3686
|
description: c.description,
|
|
2636
|
-
|
|
3687
|
+
// Phase 4 (plan 2026-08-20-ui-bridge-action-declaration-shape):
|
|
3688
|
+
// carry the safety annotation onto the native wire too — a walker
|
|
3689
|
+
// driving a React Native app needs the same `destructive` exclusion
|
|
3690
|
+
// the web channel gets.
|
|
3691
|
+
actions: c.actions.map((a) => ({ id: a.id, label: a.label, effect: a.effect })),
|
|
2637
3692
|
elementIds: c.elementIds
|
|
2638
3693
|
}));
|
|
2639
3694
|
return success2({ components });
|
|
@@ -2652,7 +3707,9 @@ function createServerHandlers(registry, executor, config) {
|
|
|
2652
3707
|
actions: component.actions.map((a) => ({
|
|
2653
3708
|
id: a.id,
|
|
2654
3709
|
label: a.label,
|
|
2655
|
-
description: a.description
|
|
3710
|
+
description: a.description,
|
|
3711
|
+
// Phase 4 — see the listing twin above.
|
|
3712
|
+
effect: a.effect
|
|
2656
3713
|
})),
|
|
2657
3714
|
elementIds: component.elementIds
|
|
2658
3715
|
}
|
|
@@ -2663,7 +3720,8 @@ function createServerHandlers(registry, executor, config) {
|
|
|
2663
3720
|
const body = ctx.body;
|
|
2664
3721
|
const response = await executor.executeComponentAction(id, {
|
|
2665
3722
|
action: actionId,
|
|
2666
|
-
params: body?.params
|
|
3723
|
+
params: body?.params,
|
|
3724
|
+
timeoutMs: body?.timeoutMs
|
|
2667
3725
|
});
|
|
2668
3726
|
if (!response.success) {
|
|
2669
3727
|
return error2(response.error || "Action failed", "ACTION_FAILED");
|
|
@@ -2737,6 +3795,7 @@ function createServerHandlers(registry, executor, config) {
|
|
|
2737
3795
|
});
|
|
2738
3796
|
},
|
|
2739
3797
|
getSnapshot: async (ctx) => {
|
|
3798
|
+
await registry.refreshMeasurements();
|
|
2740
3799
|
const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
|
|
2741
3800
|
const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
|
|
2742
3801
|
const snapshot = registry.createSnapshot(void 0, { visibleOnly, currentRouteOnly });
|
|
@@ -2854,9 +3913,12 @@ function createServerHandlers(registry, executor, config) {
|
|
|
2854
3913
|
//
|
|
2855
3914
|
// The mobile bridge mounts explicit NOT_SUPPORTED stubs at the runner's
|
|
2856
3915
|
// cheatsheet paths so callers (and the /manual-test skill in particular)
|
|
2857
|
-
// get a structured envelope explaining the gap rather than a
|
|
2858
|
-
//
|
|
2859
|
-
//
|
|
3916
|
+
// get a structured envelope explaining the gap rather than a bare
|
|
3917
|
+
// "no such route". These answer **HTTP 501** — `httpStatusForResponse` in
|
|
3918
|
+
// server/http-server.ts maps NOT_SUPPORTED → 501, so the status line and
|
|
3919
|
+
// the envelope agree; an unmounted path answers 404 instead. Each error
|
|
3920
|
+
// message names an in-tree mobile replacement so operators aren't left
|
|
3921
|
+
// guessing what to use instead.
|
|
2860
3922
|
aiForms: async () => {
|
|
2861
3923
|
return error2(
|
|
2862
3924
|
'ai/forms (form discovery) is runner-only; mobile React Native has no DOM to walk. Use GET /ui-bridge/control/snapshot and filter for type === "input" instead.',
|
|
@@ -3052,6 +4114,7 @@ function createServerHandlers(registry, executor, config) {
|
|
|
3052
4114
|
// that don't measure (the fallback only matches single-rooted screens but
|
|
3053
4115
|
// is good enough for the unit suite).
|
|
3054
4116
|
tapAt: async (ctx) => {
|
|
4117
|
+
await registry.refreshMeasurements();
|
|
3055
4118
|
const body = ctx.body;
|
|
3056
4119
|
const x = body?.x;
|
|
3057
4120
|
const y = body?.y;
|
|
@@ -3182,18 +4245,12 @@ function createServerHandlers(registry, executor, config) {
|
|
|
3182
4245
|
pushModal: async (ctx) => {
|
|
3183
4246
|
const detector = registry.getEnrichers().modalDetector;
|
|
3184
4247
|
if (!detector) {
|
|
3185
|
-
return error2(
|
|
3186
|
-
"modal detector not registered",
|
|
3187
|
-
"ENRICHER_UNAVAILABLE"
|
|
3188
|
-
);
|
|
4248
|
+
return error2("modal detector not registered", "ENRICHER_UNAVAILABLE");
|
|
3189
4249
|
}
|
|
3190
4250
|
const body = ctx.body;
|
|
3191
4251
|
const id = body?.id;
|
|
3192
4252
|
if (typeof id !== "string" || id.length === 0) {
|
|
3193
|
-
return error2(
|
|
3194
|
-
'pushModal requires a non-empty "id"',
|
|
3195
|
-
"INVALID_REQUEST"
|
|
3196
|
-
);
|
|
4253
|
+
return error2('pushModal requires a non-empty "id"', "INVALID_REQUEST");
|
|
3197
4254
|
}
|
|
3198
4255
|
const allowedTypes = ["modal", "sheet", "drawer", "popover", "alertdialog", "dialog"];
|
|
3199
4256
|
const requestedType = body?.type;
|
|
@@ -3226,10 +4283,7 @@ function createServerHandlers(registry, executor, config) {
|
|
|
3226
4283
|
dismissModal: async (ctx) => {
|
|
3227
4284
|
const detector = registry.getEnrichers().modalDetector;
|
|
3228
4285
|
if (!detector) {
|
|
3229
|
-
return error2(
|
|
3230
|
-
"modal detector not registered",
|
|
3231
|
-
"ENRICHER_UNAVAILABLE"
|
|
3232
|
-
);
|
|
4286
|
+
return error2("modal detector not registered", "ENRICHER_UNAVAILABLE");
|
|
3233
4287
|
}
|
|
3234
4288
|
const { id } = ctx.params;
|
|
3235
4289
|
if (typeof id !== "string" || id.length === 0) {
|
|
@@ -3259,7 +4313,7 @@ function createServerHandlers(registry, executor, config) {
|
|
|
3259
4313
|
};
|
|
3260
4314
|
if (config?.appInfo) {
|
|
3261
4315
|
response.uiBridge = {
|
|
3262
|
-
version: "0.6.
|
|
4316
|
+
version: "0.6.13",
|
|
3263
4317
|
...config.appInfo,
|
|
3264
4318
|
capabilities: ["elements", "components", "actions", "design"],
|
|
3265
4319
|
elementCount: stats.elements,
|
|
@@ -3407,8 +4461,48 @@ function extractMethod(input, init) {
|
|
|
3407
4461
|
var NetworkRequestBuffer = class {
|
|
3408
4462
|
constructor(capacity = 100) {
|
|
3409
4463
|
this.installed = false;
|
|
4464
|
+
/**
|
|
4465
|
+
* Synchronous re-entrancy depth: non-zero while the ORIGINAL `fetch` is
|
|
4466
|
+
* executing, which is exactly the window in which React Native's whatwg-fetch
|
|
4467
|
+
* polyfill constructs and opens its internal `XMLHttpRequest`.
|
|
4468
|
+
*
|
|
4469
|
+
* A counter rather than a boolean so a `fetch` called from inside a `fetch`
|
|
4470
|
+
* (an interceptor, a retry wrapper) still unwinds to zero. Safe as a plain
|
|
4471
|
+
* number: JS is single-threaded and the only writes are the paired
|
|
4472
|
+
* increment/decrement around one synchronous call.
|
|
4473
|
+
*/
|
|
4474
|
+
this.fetchDepth = { value: 0 };
|
|
3410
4475
|
this.buffer = new RingBuffer(capacity);
|
|
3411
4476
|
}
|
|
4477
|
+
/**
|
|
4478
|
+
* Patch `fetch` and `XMLHttpRequest` so `GET /sdk/network-requests` can
|
|
4479
|
+
* report what the app talked to.
|
|
4480
|
+
*
|
|
4481
|
+
* ONE REQUEST MUST PRODUCE ONE ENTRY. React Native implements `fetch` **on
|
|
4482
|
+
* top of** XHR (whatwg-fetch), so both patches observe the same request and
|
|
4483
|
+
* the buffer used to carry every call twice — same url, timestamps ~1ms
|
|
4484
|
+
* apart, and on failure one `"Network request failed"` beside one
|
|
4485
|
+
* `"XMLHttpRequest error"`. A tester reads that as a double-fetch bug in the
|
|
4486
|
+
* app and chases a defect that does not exist.
|
|
4487
|
+
*
|
|
4488
|
+
* The de-dup: `fetchDepth` is raised across the synchronous call into the
|
|
4489
|
+
* original `fetch`, which is when the polyfill builds its XHR; `open` stamps
|
|
4490
|
+
* any XHR created in that window with `__uiBridgeFromFetch`, and `send`
|
|
4491
|
+
* skips recording those. The `fetch` patch keeps the entry, because it is
|
|
4492
|
+
* the layer the app actually called and the only one that sees `Response.ok`.
|
|
4493
|
+
*
|
|
4494
|
+
* Uninstalling both patches together keeps this sound: an XHR can only be
|
|
4495
|
+
* marked while the fetch patch is live.
|
|
4496
|
+
*
|
|
4497
|
+
* KNOWN LIMIT — it is coupled to the polyfill building its XHR SYNCHRONOUSLY,
|
|
4498
|
+
* which whatwg-fetch does (its work happens in the Promise executor). If the
|
|
4499
|
+
* host's `fetch` is wrapped by an interceptor that defers the real call to a
|
|
4500
|
+
* microtask or `setTimeout`, the depth is back to 0 by the time `open` runs
|
|
4501
|
+
* and every request duplicates again — the original symptom, with no signal
|
|
4502
|
+
* that the mechanism disengaged. Duplicates in `/sdk/network-requests` are
|
|
4503
|
+
* the thing to look for; they mean this assumption stopped holding, not that
|
|
4504
|
+
* the app double-fetched.
|
|
4505
|
+
*/
|
|
3412
4506
|
install() {
|
|
3413
4507
|
if (this.installed) return;
|
|
3414
4508
|
const g = globalThis;
|
|
@@ -3416,12 +4510,20 @@ var NetworkRequestBuffer = class {
|
|
|
3416
4510
|
this.originalFetch = g.fetch;
|
|
3417
4511
|
const buffer = this.buffer;
|
|
3418
4512
|
const original = this.originalFetch;
|
|
4513
|
+
const fetchDepth = this.fetchDepth;
|
|
3419
4514
|
const patched = async (input, init) => {
|
|
3420
4515
|
const start = Date.now();
|
|
3421
4516
|
const url = extractUrl(input);
|
|
3422
4517
|
const method = extractMethod(input, init);
|
|
3423
4518
|
try {
|
|
3424
|
-
|
|
4519
|
+
let pending;
|
|
4520
|
+
fetchDepth.value += 1;
|
|
4521
|
+
try {
|
|
4522
|
+
pending = original.call(globalThis, input, init);
|
|
4523
|
+
} finally {
|
|
4524
|
+
fetchDepth.value -= 1;
|
|
4525
|
+
}
|
|
4526
|
+
const res = await pending;
|
|
3425
4527
|
buffer.push({
|
|
3426
4528
|
timestamp: start,
|
|
3427
4529
|
method,
|
|
@@ -3451,10 +4553,14 @@ var NetworkRequestBuffer = class {
|
|
|
3451
4553
|
this.originalXhrOpen = Xhr.prototype.open;
|
|
3452
4554
|
this.originalXhrSend = Xhr.prototype.send;
|
|
3453
4555
|
const buffer = this.buffer;
|
|
4556
|
+
const fetchDepth = this.fetchDepth;
|
|
3454
4557
|
const origOpen = this.originalXhrOpen;
|
|
3455
4558
|
Xhr.prototype.open = function(method, url, ...rest) {
|
|
3456
4559
|
this.__uiBridgeMethod = method.toUpperCase();
|
|
3457
4560
|
this.__uiBridgeUrl = typeof url === "string" ? url : String(url);
|
|
4561
|
+
if (fetchDepth.value > 0) {
|
|
4562
|
+
this.__uiBridgeFromFetch = true;
|
|
4563
|
+
}
|
|
3458
4564
|
return origOpen.call(
|
|
3459
4565
|
this,
|
|
3460
4566
|
method,
|
|
@@ -3464,6 +4570,9 @@ var NetworkRequestBuffer = class {
|
|
|
3464
4570
|
};
|
|
3465
4571
|
const origSend = this.originalXhrSend;
|
|
3466
4572
|
Xhr.prototype.send = function(body) {
|
|
4573
|
+
if (this.__uiBridgeFromFetch === true) {
|
|
4574
|
+
return origSend.call(this, body);
|
|
4575
|
+
}
|
|
3467
4576
|
const start = Date.now();
|
|
3468
4577
|
const method = this.__uiBridgeMethod ?? "GET";
|
|
3469
4578
|
const url = this.__uiBridgeUrl ?? "";
|
|
@@ -3543,6 +4652,16 @@ function isUnsubscribe(msg) {
|
|
|
3543
4652
|
}
|
|
3544
4653
|
|
|
3545
4654
|
// src/server/http-server.ts
|
|
4655
|
+
var ERROR_CODE_HTTP_STATUS = /* @__PURE__ */ new Map([
|
|
4656
|
+
["NOT_SUPPORTED", 501],
|
|
4657
|
+
["NOT_FOUND", 404],
|
|
4658
|
+
["METHOD_NOT_ALLOWED", 405]
|
|
4659
|
+
]);
|
|
4660
|
+
function httpStatusForResponse(response) {
|
|
4661
|
+
if (response.success) return 200;
|
|
4662
|
+
const mapped = response.code ? ERROR_CODE_HTTP_STATUS.get(response.code) : void 0;
|
|
4663
|
+
return mapped ?? 400;
|
|
4664
|
+
}
|
|
3546
4665
|
function parsePath(pattern, actual) {
|
|
3547
4666
|
const patternParts = pattern.split("/");
|
|
3548
4667
|
const actualParts = actual.split("/");
|
|
@@ -3705,6 +4824,23 @@ var WS_ROUTES = [
|
|
|
3705
4824
|
httpMethods: ["GET"]
|
|
3706
4825
|
}
|
|
3707
4826
|
];
|
|
4827
|
+
var DISCOVERY_PATHS = /* @__PURE__ */ new Set(["_help", "_routes"]);
|
|
4828
|
+
function allowedMethodsForPath(path, testHooks) {
|
|
4829
|
+
const PREFIX = "/ui-bridge/";
|
|
4830
|
+
if (!path.startsWith(PREFIX)) return [];
|
|
4831
|
+
const stripped = path.slice(PREFIX.length);
|
|
4832
|
+
const methods = [];
|
|
4833
|
+
if (DISCOVERY_PATHS.has(stripped)) methods.push("GET");
|
|
4834
|
+
for (const route of WS_ROUTES) {
|
|
4835
|
+
if (!route.httpMethods) continue;
|
|
4836
|
+
if (route.requiresTestHooks && !testHooks) continue;
|
|
4837
|
+
if (parsePath(route.pattern, stripped) === null) continue;
|
|
4838
|
+
for (const verb of route.httpMethods) {
|
|
4839
|
+
if (!methods.includes(verb)) methods.push(verb);
|
|
4840
|
+
}
|
|
4841
|
+
}
|
|
4842
|
+
return methods;
|
|
4843
|
+
}
|
|
3708
4844
|
var HANDLER_DESCRIPTIONS = {
|
|
3709
4845
|
health: "Liveness probe; returns app metadata + element count",
|
|
3710
4846
|
getElements: "Flat list of registered UI elements",
|
|
@@ -3767,6 +4903,10 @@ var ACTION_REFERENCE = [
|
|
|
3767
4903
|
{ name: "focus", params: "none" },
|
|
3768
4904
|
{ name: "blur", params: "none" },
|
|
3769
4905
|
{ name: "scroll", params: "ScrollActionParams { offset? {x,y}, to? }" },
|
|
4906
|
+
{
|
|
4907
|
+
name: "scrollIntoView",
|
|
4908
|
+
params: "ScrollIntoViewActionParams { padding? (dp, default 16) } \u2014 scrolls the element's DECLARED scrollAncestorId ScrollView; returns { alreadyVisible, scrolled }; NOT_SUPPORTED (501) without a usable declared ancestor"
|
|
4909
|
+
},
|
|
3770
4910
|
{ name: "swipe", params: "SwipeActionParams { direction, distance }" },
|
|
3771
4911
|
{ name: "toggle", params: "none \u2014 flips boolean controls" }
|
|
3772
4912
|
];
|
|
@@ -3964,7 +5104,9 @@ var NativeUIBridgeServer = class {
|
|
|
3964
5104
|
}
|
|
3965
5105
|
/**
|
|
3966
5106
|
* Set a route provider for reporting the current navigation route.
|
|
3967
|
-
* When set, `control/snapshot` responses include `currentRoute` and
|
|
5107
|
+
* When set, `control/snapshot` responses include `currentRoute` and
|
|
5108
|
+
* `segments`, plus the canonical `route` / `activeTab` aliases the web SDK's
|
|
5109
|
+
* `BridgeSnapshot` spells the same way.
|
|
3968
5110
|
*
|
|
3969
5111
|
* Also forwards the provider into the registry so the *default*
|
|
3970
5112
|
* `getSnapshot` handler (and any other call site of
|
|
@@ -3979,7 +5121,12 @@ var NativeUIBridgeServer = class {
|
|
|
3979
5121
|
this.handlers.getElements = async (ctx) => {
|
|
3980
5122
|
const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
|
|
3981
5123
|
if (currentRouteOnly && !ctx.query?.route && !ctx.body?.route) {
|
|
3982
|
-
|
|
5124
|
+
let currentRoute;
|
|
5125
|
+
try {
|
|
5126
|
+
currentRoute = provider.getCurrentRoute();
|
|
5127
|
+
} catch {
|
|
5128
|
+
currentRoute = null;
|
|
5129
|
+
}
|
|
3983
5130
|
if (currentRoute) {
|
|
3984
5131
|
ctx.query = { ...ctx.query, route: currentRoute };
|
|
3985
5132
|
}
|
|
@@ -3989,11 +5136,26 @@ var NativeUIBridgeServer = class {
|
|
|
3989
5136
|
this.handlers.getSnapshot = async (ctx) => {
|
|
3990
5137
|
const visibleOnly = ctx?.query?.visibleOnly === "true" || ctx?.body?.visibleOnly === true;
|
|
3991
5138
|
const currentRouteOnly = ctx?.query?.currentRouteOnly === "true" || ctx?.body?.currentRouteOnly === true;
|
|
5139
|
+
let currentRoute;
|
|
5140
|
+
try {
|
|
5141
|
+
currentRoute = provider.getCurrentRoute() ?? null;
|
|
5142
|
+
} catch {
|
|
5143
|
+
currentRoute = null;
|
|
5144
|
+
}
|
|
5145
|
+
let segments;
|
|
5146
|
+
try {
|
|
5147
|
+
segments = provider.getSegments?.();
|
|
5148
|
+
} catch {
|
|
5149
|
+
segments = void 0;
|
|
5150
|
+
}
|
|
5151
|
+
let activeTab;
|
|
5152
|
+
try {
|
|
5153
|
+
activeTab = provider.getActiveTab?.();
|
|
5154
|
+
} catch {
|
|
5155
|
+
activeTab = void 0;
|
|
5156
|
+
}
|
|
3992
5157
|
const snapshot = this.registry.createSnapshot(
|
|
3993
|
-
{
|
|
3994
|
-
currentRoute: provider.getCurrentRoute(),
|
|
3995
|
-
segments: provider.getSegments?.()
|
|
3996
|
-
},
|
|
5158
|
+
{ currentRoute, segments, activeTab },
|
|
3997
5159
|
{ visibleOnly, currentRouteOnly }
|
|
3998
5160
|
);
|
|
3999
5161
|
return { success: true, data: snapshot, timestamp: Date.now() };
|
|
@@ -4659,8 +5821,15 @@ var NativeUIBridgeServer = class {
|
|
|
4659
5821
|
}
|
|
4660
5822
|
try {
|
|
4661
5823
|
const response = await this.routeRequest(request);
|
|
5824
|
+
if (response.code === "METHOD_NOT_ALLOWED") {
|
|
5825
|
+
const allow = allowedMethodsForPath(request.path, this.config.testHooks === true);
|
|
5826
|
+
if (allow.length > 0) {
|
|
5827
|
+
headers["Allow"] = allow.join(", ");
|
|
5828
|
+
if (this.config.cors) headers["Access-Control-Expose-Headers"] = "Allow";
|
|
5829
|
+
}
|
|
5830
|
+
}
|
|
4662
5831
|
return {
|
|
4663
|
-
status: response
|
|
5832
|
+
status: httpStatusForResponse(response),
|
|
4664
5833
|
headers,
|
|
4665
5834
|
body: JSON.stringify(response)
|
|
4666
5835
|
};
|
|
@@ -4717,7 +5886,7 @@ var NativeUIBridgeServer = class {
|
|
|
4717
5886
|
timestamp: Date.now()
|
|
4718
5887
|
};
|
|
4719
5888
|
}
|
|
4720
|
-
let patternMatchedButWrongVerb =
|
|
5889
|
+
let patternMatchedButWrongVerb = DISCOVERY_PATHS.has(stripped);
|
|
4721
5890
|
for (const route of WS_ROUTES) {
|
|
4722
5891
|
const pathParams = parsePath(route.pattern, stripped);
|
|
4723
5892
|
if (!pathParams) continue;
|
|
@@ -4734,9 +5903,10 @@ var NativeUIBridgeServer = class {
|
|
|
4734
5903
|
const ctx = { params: pathParams, query, body };
|
|
4735
5904
|
return this.handlers[route.handler](ctx);
|
|
4736
5905
|
}
|
|
5906
|
+
const allowed = patternMatchedButWrongVerb ? allowedMethodsForPath(path, testHooks) : [];
|
|
4737
5907
|
return {
|
|
4738
5908
|
success: false,
|
|
4739
|
-
error: patternMatchedButWrongVerb ? `Method not allowed: ${method} ${path}` : `Route not found: ${method} ${path}`,
|
|
5909
|
+
error: patternMatchedButWrongVerb ? `Method not allowed: ${method} ${path}${allowed.length > 0 ? ` (allowed: ${allowed.join(", ")})` : ""}` : `Route not found: ${method} ${path}`,
|
|
4740
5910
|
code: patternMatchedButWrongVerb ? "METHOD_NOT_ALLOWED" : "NOT_FOUND",
|
|
4741
5911
|
timestamp: Date.now()
|
|
4742
5912
|
};
|
|
@@ -5040,6 +6210,31 @@ function createTransportLogger() {
|
|
|
5040
6210
|
}
|
|
5041
6211
|
var transportLogger = createTransportLogger();
|
|
5042
6212
|
|
|
6213
|
+
// src/transport/relay-logging.ts
|
|
6214
|
+
var CREDENTIAL_PARAMS = ["token", "access_token", "auth", "authorization", "key", "sig"];
|
|
6215
|
+
var CREDENTIAL_PARAM_RE = new RegExp(`([?&](?:${CREDENTIAL_PARAMS.join("|")})=)[^&#]*`, "gi");
|
|
6216
|
+
var USERINFO_RE = /^([a-z][a-z0-9+.-]*:\/\/)[^/?#@]*@/i;
|
|
6217
|
+
function redactRelayUrl(url) {
|
|
6218
|
+
return url.replace(USERINFO_RE, "$1<redacted>@").replace(CREDENTIAL_PARAM_RE, "$1<redacted>");
|
|
6219
|
+
}
|
|
6220
|
+
function describeCloseEvent(event) {
|
|
6221
|
+
const e = event ?? {};
|
|
6222
|
+
const code = typeof e.code === "number" ? String(e.code) : "unknown";
|
|
6223
|
+
const reason = typeof e.reason === "string" && e.reason.length > 0 ? e.reason : "(none)";
|
|
6224
|
+
const wasClean = typeof e.wasClean === "boolean" ? String(e.wasClean) : "unknown";
|
|
6225
|
+
return `code=${code} reason=${reason} wasClean=${wasClean}`;
|
|
6226
|
+
}
|
|
6227
|
+
function describeErrorEvent(event) {
|
|
6228
|
+
const e = event ?? {};
|
|
6229
|
+
const parts = [];
|
|
6230
|
+
if (typeof e.message === "string" && e.message.length > 0) parts.push(`message=${e.message}`);
|
|
6231
|
+
if (e.error instanceof Error) parts.push(`error=${e.error.message}`);
|
|
6232
|
+
else if (typeof e.error === "string" && e.error.length > 0) parts.push(`error=${e.error}`);
|
|
6233
|
+
if (typeof e.code === "number") parts.push(`code=${e.code}`);
|
|
6234
|
+
if (parts.length === 0) parts.push("message=(none reported by the transport)");
|
|
6235
|
+
return parts.join(" ");
|
|
6236
|
+
}
|
|
6237
|
+
|
|
5043
6238
|
// src/transport/DeviceAnnouncer.ts
|
|
5044
6239
|
var DEFAULT_PORT = 8087;
|
|
5045
6240
|
var RECONNECT_INITIAL_DELAY_MS = 2e3;
|
|
@@ -5109,7 +6304,8 @@ var DeviceAnnouncer = class {
|
|
|
5109
6304
|
}
|
|
5110
6305
|
if (this.stopped) return;
|
|
5111
6306
|
const url = `${this.config.cloudRelayUrl}?token=${encodeURIComponent(this.config.cloudToken)}`;
|
|
5112
|
-
|
|
6307
|
+
const safeUrl = redactRelayUrl(url);
|
|
6308
|
+
transportLogger.log("[DeviceAnnouncer] Cloud relay: connecting to", safeUrl);
|
|
5113
6309
|
const ws = new WebSocket(url);
|
|
5114
6310
|
this.cloudWs = ws;
|
|
5115
6311
|
ws.onopen = () => {
|
|
@@ -5138,14 +6334,22 @@ var DeviceAnnouncer = class {
|
|
|
5138
6334
|
} catch {
|
|
5139
6335
|
}
|
|
5140
6336
|
};
|
|
5141
|
-
ws.onclose = () => {
|
|
6337
|
+
ws.onclose = (event) => {
|
|
5142
6338
|
this.state = { ...this.state, cloudConnected: false };
|
|
5143
|
-
|
|
6339
|
+
const detail = describeCloseEvent(event);
|
|
6340
|
+
if (this.stopped) {
|
|
6341
|
+
transportLogger.log(`[DeviceAnnouncer] Cloud relay closed ${detail} url=${safeUrl}`);
|
|
6342
|
+
} else {
|
|
6343
|
+
transportLogger.warn(
|
|
6344
|
+
`[DeviceAnnouncer] Cloud relay closed unexpectedly ${detail} url=${safeUrl}`
|
|
6345
|
+
);
|
|
5144
6346
|
this.scheduleReconnect();
|
|
5145
6347
|
}
|
|
5146
6348
|
};
|
|
5147
6349
|
ws.onerror = (err) => {
|
|
5148
|
-
transportLogger.warn(
|
|
6350
|
+
transportLogger.warn(
|
|
6351
|
+
`[DeviceAnnouncer] Cloud relay error: ${describeErrorEvent(err)} url=${safeUrl}`
|
|
6352
|
+
);
|
|
5149
6353
|
};
|
|
5150
6354
|
}
|
|
5151
6355
|
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
@@ -5242,7 +6446,8 @@ var CloudRelayClient = class {
|
|
|
5242
6446
|
connect() {
|
|
5243
6447
|
if (this.stopped) return;
|
|
5244
6448
|
const url = `${this.config.relayUrl}?token=${encodeURIComponent(this.config.authToken)}`;
|
|
5245
|
-
|
|
6449
|
+
const safeUrl = redactRelayUrl(url);
|
|
6450
|
+
transportLogger.log("[CloudRelayClient] Connecting to", safeUrl);
|
|
5246
6451
|
const ws = new WebSocket(url);
|
|
5247
6452
|
this.ws = ws;
|
|
5248
6453
|
ws.onopen = () => {
|
|
@@ -5263,14 +6468,22 @@ var CloudRelayClient = class {
|
|
|
5263
6468
|
ws.onmessage = (event) => {
|
|
5264
6469
|
void this.onMessage(event.data);
|
|
5265
6470
|
};
|
|
5266
|
-
ws.onclose = () => {
|
|
6471
|
+
ws.onclose = (event) => {
|
|
5267
6472
|
this._isConnected = false;
|
|
5268
|
-
|
|
6473
|
+
const detail = describeCloseEvent(event);
|
|
6474
|
+
if (this.stopped) {
|
|
6475
|
+
transportLogger.log(`[CloudRelayClient] WebSocket closed ${detail} url=${safeUrl}`);
|
|
6476
|
+
} else {
|
|
6477
|
+
transportLogger.warn(
|
|
6478
|
+
`[CloudRelayClient] WebSocket closed unexpectedly ${detail} url=${safeUrl}`
|
|
6479
|
+
);
|
|
5269
6480
|
this.scheduleReconnect();
|
|
5270
6481
|
}
|
|
5271
6482
|
};
|
|
5272
6483
|
ws.onerror = (err) => {
|
|
5273
|
-
transportLogger.warn(
|
|
6484
|
+
transportLogger.warn(
|
|
6485
|
+
`[CloudRelayClient] WebSocket error: ${describeErrorEvent(err)} url=${safeUrl}`
|
|
6486
|
+
);
|
|
5274
6487
|
};
|
|
5275
6488
|
}
|
|
5276
6489
|
async onMessage(raw) {
|
|
@@ -5549,10 +6762,20 @@ var UndoTracker = class {
|
|
|
5549
6762
|
}
|
|
5550
6763
|
};
|
|
5551
6764
|
var UIBridgeNativeContext = react.createContext(null);
|
|
6765
|
+
var EMPTY_FEATURES = {};
|
|
6766
|
+
var EMPTY_CONFIG = {};
|
|
6767
|
+
function useValueStable(value) {
|
|
6768
|
+
const signature = JSON.stringify(value);
|
|
6769
|
+
return react.useMemo(() => value, [signature]);
|
|
6770
|
+
}
|
|
6771
|
+
function readWindowViewport() {
|
|
6772
|
+
const win = reactNative.Dimensions.get("window");
|
|
6773
|
+
return { width: win.width, height: win.height };
|
|
6774
|
+
}
|
|
5552
6775
|
function UIBridgeNativeProvider({
|
|
5553
6776
|
children,
|
|
5554
|
-
features =
|
|
5555
|
-
config =
|
|
6777
|
+
features: featuresProp = EMPTY_FEATURES,
|
|
6778
|
+
config: configProp = EMPTY_CONFIG,
|
|
5556
6779
|
onEvent,
|
|
5557
6780
|
serverAdapter,
|
|
5558
6781
|
navigationProvider,
|
|
@@ -5563,6 +6786,8 @@ function UIBridgeNativeProvider({
|
|
|
5563
6786
|
enableMdnsAnnounce,
|
|
5564
6787
|
deviceId
|
|
5565
6788
|
}) {
|
|
6789
|
+
const features = useValueStable(featuresProp);
|
|
6790
|
+
const config = useValueStable(configProp);
|
|
5566
6791
|
const registryRef = react.useRef(null);
|
|
5567
6792
|
const executorRef = react.useRef(null);
|
|
5568
6793
|
const modalDetectorRef = react.useRef(null);
|
|
@@ -5575,6 +6800,7 @@ function UIBridgeNativeProvider({
|
|
|
5575
6800
|
onEvent
|
|
5576
6801
|
});
|
|
5577
6802
|
registryRef.current.setPixelRatio(reactNative.PixelRatio.get());
|
|
6803
|
+
registryRef.current.setViewportProvider(readWindowViewport);
|
|
5578
6804
|
setGlobalRegistry(registryRef.current);
|
|
5579
6805
|
}
|
|
5580
6806
|
const registry = registryRef.current;
|
|
@@ -5609,16 +6835,10 @@ function UIBridgeNativeProvider({
|
|
|
5609
6835
|
// the `testHooks` value when undefined, so passing it through verbatim
|
|
5610
6836
|
// keeps existing consumers unchanged.
|
|
5611
6837
|
observability: features.observability,
|
|
5612
|
-
// Injected device-viewport getter for POST /control/page-health
|
|
5613
|
-
//
|
|
5614
|
-
//
|
|
5615
|
-
|
|
5616
|
-
// already has a live react-native import, so it's the safe injection
|
|
5617
|
-
// point. See feedback_metro_require_gotcha for the full incident.
|
|
5618
|
-
viewportProvider: () => {
|
|
5619
|
-
const win = reactNative.Dimensions.get("window");
|
|
5620
|
-
return { width: win.width, height: win.height };
|
|
5621
|
-
}
|
|
6838
|
+
// Injected device-viewport getter for POST /control/page-health and the
|
|
6839
|
+
// design handlers. Same source as the registry's clipping bounds — see
|
|
6840
|
+
// `readWindowViewport`.
|
|
6841
|
+
viewportProvider: readWindowViewport
|
|
5622
6842
|
});
|
|
5623
6843
|
server.setAdapter(serverAdapter);
|
|
5624
6844
|
if (navigationProvider) {
|
|
@@ -5716,10 +6936,7 @@ function UIBridgeNativeProvider({
|
|
|
5716
6936
|
appInfo: config.appInfo,
|
|
5717
6937
|
testHooks: features.testHooks === true,
|
|
5718
6938
|
observability: features.observability,
|
|
5719
|
-
viewportProvider:
|
|
5720
|
-
const win = reactNative.Dimensions.get("window");
|
|
5721
|
-
return { width: win.width, height: win.height };
|
|
5722
|
-
}
|
|
6939
|
+
viewportProvider: readWindowViewport
|
|
5723
6940
|
});
|
|
5724
6941
|
if (navigationProvider) bareServer.setNavigationProvider(navigationProvider);
|
|
5725
6942
|
if (screenshotProvider) bareServer.setScreenshotProvider(screenshotProvider);
|
|
@@ -5924,6 +7141,7 @@ function useUIElement(options) {
|
|
|
5924
7141
|
const propsRef = react.useRef({});
|
|
5925
7142
|
const registeredRef = react.useRef(false);
|
|
5926
7143
|
const registeredIdRef = react.useRef(null);
|
|
7144
|
+
const autoPublishedLabelRef = react.useRef(void 0);
|
|
5927
7145
|
const bridgeRef = react.useRef(bridge);
|
|
5928
7146
|
bridgeRef.current = bridge;
|
|
5929
7147
|
const nodeRef = react.useRef(null);
|
|
@@ -5963,6 +7181,7 @@ function useUIElement(options) {
|
|
|
5963
7181
|
autoRegister = true,
|
|
5964
7182
|
onStateChange,
|
|
5965
7183
|
parentPath,
|
|
7184
|
+
scrollAncestorId,
|
|
5966
7185
|
style,
|
|
5967
7186
|
stateStyles: stateStylesProp,
|
|
5968
7187
|
handlers: handlersProp,
|
|
@@ -5987,22 +7206,38 @@ function useUIElement(options) {
|
|
|
5987
7206
|
testId: id,
|
|
5988
7207
|
accessibilityLabel: label,
|
|
5989
7208
|
registrationRoute: bridge.getCurrentRoute(),
|
|
7209
|
+
scrollAncestorId,
|
|
5990
7210
|
flatStyle: flattenStyle(style),
|
|
5991
7211
|
stateStyles: flattenStateStyles(stateStylesProp)
|
|
5992
7212
|
});
|
|
5993
7213
|
registeredRef.current = true;
|
|
5994
7214
|
registeredIdRef.current = id;
|
|
7215
|
+
autoPublishedLabelRef.current = typeof label === "string" ? label : void 0;
|
|
5995
7216
|
if (typeof valueProp === "string") {
|
|
5996
7217
|
bridge.registry.updateElementState(id, { value: valueProp });
|
|
5997
7218
|
}
|
|
5998
7219
|
setRegistered(true);
|
|
5999
7220
|
scheduleDeadButtonWarning(bridge, id, type);
|
|
6000
|
-
}, [
|
|
7221
|
+
}, [
|
|
7222
|
+
bridge,
|
|
7223
|
+
id,
|
|
7224
|
+
type,
|
|
7225
|
+
label,
|
|
7226
|
+
actions,
|
|
7227
|
+
customActions,
|
|
7228
|
+
treePath,
|
|
7229
|
+
scrollAncestorId,
|
|
7230
|
+
style,
|
|
7231
|
+
stateStylesProp,
|
|
7232
|
+
valueProp,
|
|
7233
|
+
ref
|
|
7234
|
+
]);
|
|
6001
7235
|
const unregister = react.useCallback(() => {
|
|
6002
7236
|
if (!bridge || !registeredRef.current) return;
|
|
6003
7237
|
bridge.registry.unregisterElement(registeredIdRef.current ?? id);
|
|
6004
7238
|
registeredRef.current = false;
|
|
6005
7239
|
registeredIdRef.current = null;
|
|
7240
|
+
autoPublishedLabelRef.current = void 0;
|
|
6006
7241
|
setRegistered(false);
|
|
6007
7242
|
}, [bridge, id]);
|
|
6008
7243
|
const onStateChangeRef = react.useRef(onStateChange);
|
|
@@ -6030,7 +7265,7 @@ function useUIElement(options) {
|
|
|
6030
7265
|
writeLayout({ x, y, width, height, pageX, pageY });
|
|
6031
7266
|
});
|
|
6032
7267
|
} else {
|
|
6033
|
-
writeLayout({ x, y, width, height, pageX: x, pageY: y });
|
|
7268
|
+
writeLayout({ x, y, width, height, pageX: x, pageY: y, pageOriginUnmeasured: true });
|
|
6034
7269
|
}
|
|
6035
7270
|
},
|
|
6036
7271
|
// `ref` is the once-per-hook interceptor object — identity-stable.
|
|
@@ -6051,7 +7286,7 @@ function useUIElement(options) {
|
|
|
6051
7286
|
unregisterRef.current();
|
|
6052
7287
|
}
|
|
6053
7288
|
};
|
|
6054
|
-
}, [autoRegister, bridge]);
|
|
7289
|
+
}, [autoRegister, bridge, id]);
|
|
6055
7290
|
react.useEffect(() => {
|
|
6056
7291
|
if (!bridge || !registered) return;
|
|
6057
7292
|
const node = ref.current;
|
|
@@ -6142,6 +7377,16 @@ function useUIElement(options) {
|
|
|
6142
7377
|
if (current === valueProp) return;
|
|
6143
7378
|
bridge.registry.updateElementState(id, { value: valueProp });
|
|
6144
7379
|
}, [bridge, registered, id, valueProp]);
|
|
7380
|
+
react.useEffect(() => {
|
|
7381
|
+
if (!bridge || !registered) return;
|
|
7382
|
+
if (typeof label !== "string") return;
|
|
7383
|
+
autoPublishedLabelRef.current = label;
|
|
7384
|
+
bridge.registry.updateElementMeta(id, {
|
|
7385
|
+
label,
|
|
7386
|
+
accessibilityLabel: label,
|
|
7387
|
+
testId: id
|
|
7388
|
+
});
|
|
7389
|
+
}, [bridge, registered, id, label]);
|
|
6145
7390
|
react.useCallback(
|
|
6146
7391
|
(props) => {
|
|
6147
7392
|
propsRef.current = { ...propsRef.current, ...props };
|
|
@@ -6184,7 +7429,8 @@ function useUIElement(options) {
|
|
|
6184
7429
|
accessibilityLabel: newLabel,
|
|
6185
7430
|
testId: id
|
|
6186
7431
|
});
|
|
6187
|
-
|
|
7432
|
+
const alreadyPublishedBySdk = newLabel === autoPublishedLabelRef.current;
|
|
7433
|
+
if (!changed && !alreadyPublishedBySdk && !warnedRedundantUpdateLabelIds.has(id)) {
|
|
6188
7434
|
warnedRedundantUpdateLabelIds.add(id);
|
|
6189
7435
|
console.warn(
|
|
6190
7436
|
`[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.`
|
|
@@ -6240,36 +7486,56 @@ function useUIElementWithProps(options) {
|
|
|
6240
7486
|
}
|
|
6241
7487
|
function useUIComponent(options) {
|
|
6242
7488
|
const bridge = useUIBridgeNativeOptional();
|
|
7489
|
+
const [registered, setRegistered] = react.useState(false);
|
|
7490
|
+
const [publishVersion, setPublishVersion] = react.useState(0);
|
|
7491
|
+
const publishedIdRef = react.useRef(null);
|
|
6243
7492
|
const registeredRef = react.useRef(false);
|
|
6244
7493
|
const registeredIdRef = react.useRef(null);
|
|
6245
7494
|
const actionsRef = react.useRef(options.actions || []);
|
|
6246
7495
|
const elementIdsRef = react.useRef(options.elementIds || []);
|
|
6247
7496
|
const { id, name, description, autoRegister = true } = options;
|
|
7497
|
+
const actionsSignature = JSON.stringify(
|
|
7498
|
+
(options.actions ?? []).map((a) => [a.id, a.label, a.description, a.paramSchema, a.effect])
|
|
7499
|
+
);
|
|
7500
|
+
const elementIdsSignature = JSON.stringify(options.elementIds ?? []);
|
|
7501
|
+
react.useEffect(() => {
|
|
7502
|
+
if (options.actions !== void 0) actionsRef.current = options.actions;
|
|
7503
|
+
}, [options.actions]);
|
|
6248
7504
|
react.useEffect(() => {
|
|
6249
|
-
|
|
6250
|
-
|
|
6251
|
-
|
|
7505
|
+
if (options.elementIds !== void 0) elementIdsRef.current = options.elementIds;
|
|
7506
|
+
}, [elementIdsSignature]);
|
|
7507
|
+
const toRegistryActions = react.useCallback(
|
|
7508
|
+
() => actionsRef.current.map(({ handler: declaredHandler, ...rest }) => ({
|
|
7509
|
+
...rest,
|
|
7510
|
+
handler: (params, handlerOptions) => {
|
|
7511
|
+
const latest = actionsRef.current.find((a) => a.id === rest.id);
|
|
7512
|
+
return (latest?.handler ?? declaredHandler)(params, handlerOptions);
|
|
7513
|
+
}
|
|
7514
|
+
})),
|
|
7515
|
+
[]
|
|
7516
|
+
);
|
|
6252
7517
|
const register = react.useCallback(() => {
|
|
6253
7518
|
if (!bridge || registeredRef.current) return;
|
|
6254
7519
|
bridge.registry.registerComponent(id, {
|
|
6255
7520
|
name,
|
|
6256
7521
|
description,
|
|
6257
|
-
actions:
|
|
6258
|
-
id: a.id,
|
|
6259
|
-
label: a.label,
|
|
6260
|
-
description: a.description,
|
|
6261
|
-
handler: a.handler
|
|
6262
|
-
})),
|
|
7522
|
+
actions: toRegistryActions(),
|
|
6263
7523
|
elementIds: elementIdsRef.current
|
|
6264
7524
|
});
|
|
6265
7525
|
registeredRef.current = true;
|
|
6266
7526
|
registeredIdRef.current = id;
|
|
6267
|
-
|
|
7527
|
+
setRegistered(true);
|
|
7528
|
+
if (publishedIdRef.current !== id) {
|
|
7529
|
+
publishedIdRef.current = id;
|
|
7530
|
+
setPublishVersion((v) => v + 1);
|
|
7531
|
+
}
|
|
7532
|
+
}, [bridge, id, name, description, toRegistryActions]);
|
|
6268
7533
|
const unregister = react.useCallback(() => {
|
|
6269
7534
|
if (!bridge || !registeredRef.current) return;
|
|
6270
7535
|
bridge.registry.unregisterComponent(registeredIdRef.current ?? id);
|
|
6271
7536
|
registeredRef.current = false;
|
|
6272
7537
|
registeredIdRef.current = null;
|
|
7538
|
+
setRegistered(false);
|
|
6273
7539
|
}, [bridge, id]);
|
|
6274
7540
|
const executeAction = react.useCallback(
|
|
6275
7541
|
async (actionId, params) => {
|
|
@@ -6287,25 +7553,47 @@ function useUIComponent(options) {
|
|
|
6287
7553
|
},
|
|
6288
7554
|
[bridge, id]
|
|
6289
7555
|
);
|
|
7556
|
+
const publishElementIds = react.useCallback(() => {
|
|
7557
|
+
if (!registeredRef.current || !bridge) return;
|
|
7558
|
+
const changed = bridge.registry.updateComponentMeta(registeredIdRef.current ?? id, {
|
|
7559
|
+
elementIds: elementIdsRef.current
|
|
7560
|
+
});
|
|
7561
|
+
if (changed) {
|
|
7562
|
+
setPublishVersion((v) => v + 1);
|
|
7563
|
+
}
|
|
7564
|
+
}, [bridge, id]);
|
|
6290
7565
|
const updateActions = react.useCallback(
|
|
6291
7566
|
(actions) => {
|
|
6292
7567
|
actionsRef.current = actions;
|
|
6293
7568
|
if (registeredRef.current && bridge) {
|
|
6294
|
-
|
|
6295
|
-
|
|
6296
|
-
|
|
7569
|
+
const targetId = registeredIdRef.current ?? id;
|
|
7570
|
+
const changed = bridge.registry.updateComponentMeta(targetId, {
|
|
7571
|
+
actions: toRegistryActions()
|
|
7572
|
+
});
|
|
7573
|
+
if (changed) {
|
|
7574
|
+
setPublishVersion((v) => v + 1);
|
|
7575
|
+
}
|
|
6297
7576
|
}
|
|
6298
7577
|
},
|
|
6299
|
-
[bridge, id,
|
|
7578
|
+
[bridge, id, toRegistryActions]
|
|
7579
|
+
);
|
|
7580
|
+
const addElement = react.useCallback(
|
|
7581
|
+
(elementId) => {
|
|
7582
|
+
if (!elementIdsRef.current.includes(elementId)) {
|
|
7583
|
+
elementIdsRef.current = [...elementIdsRef.current, elementId];
|
|
7584
|
+
publishElementIds();
|
|
7585
|
+
}
|
|
7586
|
+
},
|
|
7587
|
+
[publishElementIds]
|
|
7588
|
+
);
|
|
7589
|
+
const removeElement = react.useCallback(
|
|
7590
|
+
(elementId) => {
|
|
7591
|
+
if (!elementIdsRef.current.includes(elementId)) return;
|
|
7592
|
+
elementIdsRef.current = elementIdsRef.current.filter((eid) => eid !== elementId);
|
|
7593
|
+
publishElementIds();
|
|
7594
|
+
},
|
|
7595
|
+
[publishElementIds]
|
|
6300
7596
|
);
|
|
6301
|
-
const addElement = react.useCallback((elementId) => {
|
|
6302
|
-
if (!elementIdsRef.current.includes(elementId)) {
|
|
6303
|
-
elementIdsRef.current = [...elementIdsRef.current, elementId];
|
|
6304
|
-
}
|
|
6305
|
-
}, []);
|
|
6306
|
-
const removeElement = react.useCallback((elementId) => {
|
|
6307
|
-
elementIdsRef.current = elementIdsRef.current.filter((eid) => eid !== elementId);
|
|
6308
|
-
}, []);
|
|
6309
7597
|
const registerRef = react.useRef(register);
|
|
6310
7598
|
const unregisterRef = react.useRef(unregister);
|
|
6311
7599
|
react.useEffect(() => {
|
|
@@ -6321,13 +7609,32 @@ function useUIComponent(options) {
|
|
|
6321
7609
|
unregisterRef.current();
|
|
6322
7610
|
}
|
|
6323
7611
|
};
|
|
6324
|
-
}, [autoRegister, bridge]);
|
|
6325
|
-
|
|
6326
|
-
if (!bridge) return
|
|
6327
|
-
|
|
6328
|
-
|
|
7612
|
+
}, [autoRegister, bridge, id]);
|
|
7613
|
+
react.useEffect(() => {
|
|
7614
|
+
if (!bridge || !registered) return;
|
|
7615
|
+
const targetId = registeredIdRef.current ?? id;
|
|
7616
|
+
const changed = bridge.registry.updateComponentMeta(targetId, {
|
|
7617
|
+
name,
|
|
7618
|
+
description,
|
|
7619
|
+
...options.actions !== void 0 ? { actions: toRegistryActions() } : {},
|
|
7620
|
+
...options.elementIds !== void 0 ? { elementIds: elementIdsRef.current } : {}
|
|
7621
|
+
});
|
|
7622
|
+
if (changed) {
|
|
7623
|
+
setPublishVersion((v) => v + 1);
|
|
7624
|
+
}
|
|
7625
|
+
}, [
|
|
7626
|
+
bridge,
|
|
7627
|
+
registered,
|
|
7628
|
+
id,
|
|
7629
|
+
name,
|
|
7630
|
+
description,
|
|
7631
|
+
actionsSignature,
|
|
7632
|
+
elementIdsSignature,
|
|
7633
|
+
toRegistryActions
|
|
7634
|
+
]);
|
|
7635
|
+
const registeredComponent = registered && bridge ? bridge.registry.getComponent(id) ?? null : null;
|
|
6329
7636
|
return {
|
|
6330
|
-
registered
|
|
7637
|
+
registered,
|
|
6331
7638
|
executeAction,
|
|
6332
7639
|
register,
|
|
6333
7640
|
unregister,
|