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