@symbiote-native/engine 1.3.1 → 1.4.0

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.
@@ -0,0 +1,24 @@
1
+ export declare const BUBBLING_EVENTS: Readonly<Record<string, string>>;
2
+ export declare const DIRECT_EVENTS: Readonly<Record<string, string>>;
3
+ export declare const TOUCH_START = "topTouchStart";
4
+ export declare const TOUCH_MOVE = "topTouchMove";
5
+ export declare const TOUCH_END = "topTouchEnd";
6
+ export declare const TOUCH_CANCEL = "topTouchCancel";
7
+ export declare const PRESS = "press";
8
+ export declare const RESPONDER_GRANT = "responderGrant";
9
+ export declare const RESPONDER_REJECT = "responderReject";
10
+ export declare const RESPONDER_START = "responderStart";
11
+ export declare const RESPONDER_MOVE = "responderMove";
12
+ export declare const RESPONDER_END = "responderEnd";
13
+ export declare const RESPONDER_RELEASE = "responderRelease";
14
+ export declare const RESPONDER_TERMINATE = "responderTerminate";
15
+ export declare const RESPONDER_TERMINATION_REQUEST = "responderTerminationRequest";
16
+ export declare const SHOULD_SET_NAMES: Readonly<Record<'start' | 'move', {
17
+ readonly capture: string;
18
+ readonly bubble: string;
19
+ }>>;
20
+ export declare const PRESS_IN = "pressIn";
21
+ export declare const PRESS_OUT = "pressOut";
22
+ export declare const LONG_PRESS = "longPress";
23
+ export declare const DEFAULT_LONG_PRESS_MS = 500;
24
+ export declare const LONG_PRESS_DEACTIVATION_DISTANCE = 10;
@@ -0,0 +1,80 @@
1
+ // Raw Fabric event name -> listener name, and the synthesized names that have no raw event at all.
2
+ // Kept apart from the handler so the file holding the strings is not the file read while debugging
3
+ // a gesture
4
+ // Bubbling events walk target -> root, direct ones fire on the target alone. `press` is synthesized
5
+ // from a touch sequence and `layout` is direct, so neither is in these tables
6
+ export const BUBBLING_EVENTS = {
7
+ topFocus: 'focus',
8
+ topBlur: 'blur',
9
+ topChange: 'change',
10
+ topEndEditing: 'endEditing',
11
+ topSubmitEditing: 'submitEditing',
12
+ topKeyPress: 'keyPress',
13
+ };
14
+ export const DIRECT_EVENTS = {
15
+ topLayout: 'layout',
16
+ topScroll: 'scroll',
17
+ topScrollBeginDrag: 'scrollBeginDrag',
18
+ topScrollEndDrag: 'scrollEndDrag',
19
+ topMomentumScrollBegin: 'momentumScrollBegin',
20
+ topMomentumScrollEnd: 'momentumScrollEnd',
21
+ topSelectionChange: 'selectionChange',
22
+ topContentSizeChange: 'contentSizeChange',
23
+ topLoadStart: 'loadStart',
24
+ topLoad: 'load',
25
+ topLoadEnd: 'loadEnd',
26
+ topError: 'error',
27
+ topProgress: 'progress',
28
+ topPartialLoad: 'partialLoad',
29
+ topRefresh: 'refresh',
30
+ topShow: 'show',
31
+ topRequestClose: 'requestClose',
32
+ topDismiss: 'dismiss',
33
+ topOrientationChange: 'orientationChange',
34
+ topTextLayout: 'textLayout',
35
+ topScrollToTop: 'scrollToTop',
36
+ // From RN's base ViewConfig, so any view may emit them. `accessibilityAction` fires on both
37
+ // platforms; the other three have no Android producer and are inert there
38
+ topAccessibilityAction: 'accessibilityAction',
39
+ topAccessibilityTap: 'accessibilityTap',
40
+ topMagicTap: 'magicTap',
41
+ topAccessibilityEscape: 'accessibilityEscape',
42
+ };
43
+ export const TOUCH_START = 'topTouchStart';
44
+ export const TOUCH_MOVE = 'topTouchMove';
45
+ export const TOUCH_END = 'topTouchEnd';
46
+ export const TOUCH_CANCEL = 'topTouchCancel';
47
+ export const PRESS = 'press';
48
+ // The responder protocol (PanResponder / Touchable), post-`on` names: `onResponderMove` is
49
+ // `responderMove` here. RN's negotiation and its two-phase walk live in `./responder`
50
+ export const RESPONDER_GRANT = 'responderGrant';
51
+ export const RESPONDER_REJECT = 'responderReject';
52
+ export const RESPONDER_START = 'responderStart';
53
+ export const RESPONDER_MOVE = 'responderMove';
54
+ export const RESPONDER_END = 'responderEnd';
55
+ export const RESPONDER_RELEASE = 'responderRelease';
56
+ export const RESPONDER_TERMINATE = 'responderTerminate';
57
+ export const RESPONDER_TERMINATION_REQUEST = 'responderTerminationRequest';
58
+ // The should-set pair asked per phase, as one table т.к. the only thing separating a start
59
+ // negotiation from a move one is which two names get asked
60
+ export const SHOULD_SET_NAMES = {
61
+ start: {
62
+ capture: 'startShouldSetResponderCapture',
63
+ bubble: 'startShouldSetResponder',
64
+ },
65
+ move: {
66
+ capture: 'moveShouldSetResponderCapture',
67
+ bubble: 'moveShouldSetResponder',
68
+ },
69
+ };
70
+ // Synthesized alongside `press` so a Pressable can paint its pressed state. Both fire on the node
71
+ // the touch STARTED on, `pressOut` on end and on cancel
72
+ export const PRESS_IN = 'pressIn';
73
+ export const PRESS_OUT = 'pressOut';
74
+ // Synthesized from a sustained hold, so a bare Text or View gets `onLongPress` with no native
75
+ // event behind it. A fired long press suppresses the tap on release
76
+ export const LONG_PRESS = 'longPress';
77
+ export const DEFAULT_LONG_PRESS_MS = 500;
78
+ // How far the touch may drift before the pending long press is cancelled
79
+ // (`Pressability.DEFAULT_LONG_PRESS_DEACTIVATION_DISTANCE`)
80
+ export const LONG_PRESS_DEACTIVATION_DISTANCE = 10;
@@ -0,0 +1,20 @@
1
+ import { type ISymbioteNode } from '../node';
2
+ export interface IPressGesture {
3
+ owner: ISymbioteNode;
4
+ longPressTimer: ReturnType<typeof setTimeout> | undefined;
5
+ longPressFired: boolean;
6
+ longPressStart: {
7
+ x: number;
8
+ y: number;
9
+ } | undefined;
10
+ }
11
+ export declare const activePresses: Set<IPressGesture>;
12
+ export declare function clearLongPress(press: IPressGesture): void;
13
+ export declare function takeAllPresses(): IPressGesture[];
14
+ export declare function findActivePress(target: ISymbioteNode): IPressGesture | undefined;
15
+ export declare function takeEndedPresses(nativeEvent: Record<string, unknown>): IPressGesture[];
16
+ export declare function readTouchPoint(nativeEvent: Record<string, unknown>): {
17
+ x: number;
18
+ y: number;
19
+ } | undefined;
20
+ export declare function hasRemainingTouchWithin(owner: ISymbioteNode, nativeEvent: Record<string, unknown>): boolean;
@@ -0,0 +1,89 @@
1
+ // The synthesized tap: Fabric has no `press` event, so one is built from a touch sequence. This
2
+ // module owns the in-flight gestures and the long-press clock, nothing about delivery
3
+ // A press is INDEPENDENT of the responder: `negotiateResponder` runs on the same touch frames and
4
+ // neither consumes the other, т.к. a View can be a Pressable and a PanResponder target at once
5
+ import { isRecord } from '../type-guards.js';
6
+ import { isSymbioteNode } from '../node.js';
7
+ import { endsWithin } from './delivery.js';
8
+ // Each unrelated target owns an independent press, and further fingers under the same owner JOIN
9
+ // it, so one target still sees exactly one `pressIn` / `press` / `pressOut` lifecycle
10
+ export const activePresses = new Set();
11
+ export function clearLongPress(press) {
12
+ if (press.longPressTimer !== undefined) {
13
+ clearTimeout(press.longPressTimer);
14
+ press.longPressTimer = undefined;
15
+ }
16
+ }
17
+ export function takeAllPresses() {
18
+ const presses = [...activePresses];
19
+ activePresses.clear();
20
+ for (const press of presses)
21
+ clearLongPress(press);
22
+ return presses;
23
+ }
24
+ export function findActivePress(target) {
25
+ for (const press of activePresses) {
26
+ if (endsWithin(target, press.owner))
27
+ return press;
28
+ }
29
+ return undefined;
30
+ }
31
+ // Every press whose last finger left in this frame, removed from the active set. An owner with a
32
+ // finger still down keeps its press, which is what makes a multi-touch lift not a release
33
+ export function takeEndedPresses(nativeEvent) {
34
+ const ended = [];
35
+ for (const press of activePresses) {
36
+ if (hasRemainingTouchWithin(press.owner, nativeEvent))
37
+ continue;
38
+ activePresses.delete(press);
39
+ clearLongPress(press);
40
+ ended.push(press);
41
+ }
42
+ return ended;
43
+ }
44
+ function pagePointOf(source) {
45
+ if (!source)
46
+ return undefined;
47
+ const { pageX, pageY } = source;
48
+ if (typeof pageX === 'number' && typeof pageY === 'number')
49
+ return { x: pageX, y: pageY };
50
+ return undefined;
51
+ }
52
+ // The changed finger's page coordinate, falling back to the active-touch list and then to
53
+ // undefined, which callers read as "skip the coordinate-dependent logic" rather than guessing
54
+ export function readTouchPoint(nativeEvent) {
55
+ const direct = pagePointOf(nativeEvent);
56
+ if (direct)
57
+ return direct;
58
+ // `changedTouches` identifies the finger for THIS frame, so it is read before the full
59
+ // active-touch list, whose first entry may belong to another simultaneously pressed target
60
+ for (const key of ['changedTouches', 'touches']) {
61
+ const touches = nativeEvent[key];
62
+ if (!Array.isArray(touches))
63
+ continue;
64
+ for (const touch of touches) {
65
+ if (!isRecord(touch))
66
+ continue;
67
+ const point = pagePointOf(touch);
68
+ if (point)
69
+ return point;
70
+ }
71
+ }
72
+ return undefined;
73
+ }
74
+ // Whether any touch still down started inside `owner`. RN's `noResponderTouches` walks
75
+ // `nativeEvent.touches` the same way; a headless smoke fires an empty `{}`, so no remaining touch,
76
+ // which preserves single-touch behavior
77
+ export function hasRemainingTouchWithin(owner, nativeEvent) {
78
+ const touches = nativeEvent.touches;
79
+ if (!Array.isArray(touches))
80
+ return false;
81
+ for (const touch of touches) {
82
+ if (!isRecord(touch))
83
+ continue;
84
+ const target = touch.target;
85
+ if (isSymbioteNode(target) && endsWithin(target, owner))
86
+ return true;
87
+ }
88
+ return false;
89
+ }
@@ -0,0 +1,6 @@
1
+ import { type ISymbioteNode } from '../node';
2
+ export declare function heldResponder(): ISymbioteNode | undefined;
3
+ export declare function clearResponder(): void;
4
+ /** Release the responder to native, on a final touch end or a terminate. */
5
+ export declare function releaseResponderToNative(node: ISymbioteNode): void;
6
+ export declare function negotiateResponder(target: ISymbioteNode, phase: 'start' | 'move', nativeEvent: Record<string, unknown>): void;
@@ -0,0 +1,124 @@
1
+ // The responder protocol: who owns the in-flight gesture, and the negotiation that moves it.
2
+ // Split from `./index` so the touch handler reads as four phases rather than as the negotiation
3
+ // `currentResponder` is module state reached from outside through `heldResponder` and
4
+ // `clearResponder`, т.к. the touch handler must read it between its own phases
5
+ import { dlog, isDebug } from '../debug.js';
6
+ import { setIsJSResponder } from '../imperative.js';
7
+ import { hasListenerFor } from '../node.js';
8
+ import { callOwnListener, lowestCommonAncestor, pathToRoot } from './delivery.js';
9
+ import { RESPONDER_GRANT, RESPONDER_REJECT, RESPONDER_TERMINATE, RESPONDER_TERMINATION_REQUEST, SHOULD_SET_NAMES, } from './names.js';
10
+ // The node that claimed the responder for the in-flight touch, or undefined when nobody did.
11
+ // Receives move and release/terminate
12
+ let currentResponder;
13
+ export function heldResponder() {
14
+ return currentResponder;
15
+ }
16
+ export function clearResponder() {
17
+ currentResponder = undefined;
18
+ }
19
+ // RN's two-phase should-set walk: CAPTURE root to deepest, then BUBBLE deepest to root, first true
20
+ // wins. `skip` drops the deepest node when it IS the current responder, т.к. asking the holder to
21
+ // re-claim would let its should-set consume the frame from under its own `onResponderMove`
22
+ function findWantsResponder(path, phase, nativeEvent, skip) {
23
+ const names = SHOULD_SET_NAMES[phase];
24
+ for (let i = path.length - 1; i >= 0; i--) {
25
+ if (path[i] !== skip &&
26
+ callOwnListener(path[i], names.capture, nativeEvent) === true) {
27
+ return path[i];
28
+ }
29
+ }
30
+ for (const node of path) {
31
+ if (node !== skip &&
32
+ callOwnListener(node, names.bubble, nativeEvent) === true)
33
+ return node;
34
+ }
35
+ return undefined;
36
+ }
37
+ // Tell native which node owns the gesture, RN's `injectGlobalResponderHandler`. The OLD owner
38
+ // first, then the new one, both in one call so no site can do half of it
39
+ // WITHOUT THIS A JS RESPONDER LOSES TO ANY SCROLL VIEW ABOVE IT, invisibly: the claim returns true,
40
+ // the native UIScrollView never learns of it, and every later move arrives as `topScroll`
41
+ function handOverNativeResponder(from, to, blockNativeResponder) {
42
+ if (isDebug()) {
43
+ dlog(`setIsJSResponder from=${from === undefined ? 'none' : 'yes'} ` +
44
+ `to=${to === undefined ? 'none' : 'yes'} block=${blockNativeResponder}`);
45
+ }
46
+ if (from !== undefined)
47
+ setIsJSResponder(from, false, blockNativeResponder);
48
+ if (to !== undefined)
49
+ setIsJSResponder(to, true, blockNativeResponder);
50
+ }
51
+ /** Release the responder to native, on a final touch end or a terminate. */
52
+ export function releaseResponderToNative(node) {
53
+ handOverNativeResponder(node, undefined, false);
54
+ }
55
+ // The slice of the target's ancestry the negotiation may ask, plus the node it must not ask
56
+ // ONE WALK, and the common case never needs a second: while a finger stays inside the view that
57
+ // claimed it, the responder IS an ancestor of the target, so the scope RN computes as a lowest
58
+ // common ancestor is already in the target's own path and an index lookup answers it
59
+ function scopeFor(target) {
60
+ const targetPath = pathToRoot(target);
61
+ if (currentResponder === undefined)
62
+ return { path: targetPath, skip: undefined };
63
+ const heldAt = targetPath.indexOf(currentResponder);
64
+ if (heldAt >= 0)
65
+ return { path: targetPath.slice(heldAt), skip: currentResponder };
66
+ // The finger left the responder's subtree, a rare frame rather than every frame
67
+ const from = lowestCommonAncestor(currentResponder, target);
68
+ return { path: from === undefined ? [] : pathToRoot(from), skip: undefined };
69
+ }
70
+ function grantTo(wants, nativeEvent) {
71
+ currentResponder = wants;
72
+ if (isDebug())
73
+ dlog(`responder granted to ${wants.component}`);
74
+ const granted = callOwnListener(wants, RESPONDER_GRANT, nativeEvent);
75
+ handOverNativeResponder(undefined, wants, granted === true);
76
+ }
77
+ // RN's `setResponderAndExtractTransfer` order: grant the TAKER, then terminate the incumbent
78
+ // RN also grants ahead of the termination request purely to read the block-native return. We have
79
+ // no native surface to block, so on the REJECT path that grant is omitted as a visible no-op
80
+ function transferTo(incumbent, wants, nativeEvent) {
81
+ // A missing termination-request listener means implicit consent (RN's default true), so only an
82
+ // explicit non-true answer keeps the incumbent
83
+ const guarded = hasListenerFor(incumbent, RESPONDER_TERMINATION_REQUEST);
84
+ const allowed = !guarded ||
85
+ callOwnListener(incumbent, RESPONDER_TERMINATION_REQUEST, nativeEvent) ===
86
+ true;
87
+ if (!allowed) {
88
+ if (isDebug())
89
+ dlog(`responder takeover of ${incumbent.component} rejected`);
90
+ callOwnListener(wants, RESPONDER_REJECT, nativeEvent);
91
+ return;
92
+ }
93
+ if (isDebug())
94
+ dlog(`responder transferred ${incumbent.component} -> ${wants.component}`);
95
+ const granted = callOwnListener(wants, RESPONDER_GRANT, nativeEvent);
96
+ callOwnListener(incumbent, RESPONDER_TERMINATE, nativeEvent);
97
+ currentResponder = wants;
98
+ handOverNativeResponder(incumbent, wants, granted === true);
99
+ }
100
+ // Negotiate (or re-negotiate) the responder for a touch start or move. With nobody holding it the
101
+ // winner is granted; with an incumbent it is asked to relinquish first
102
+ export function negotiateResponder(target, phase, nativeEvent) {
103
+ const { path, skip } = scopeFor(target);
104
+ if (path.length === 0)
105
+ return;
106
+ const wants = findWantsResponder(path, phase, nativeEvent, skip);
107
+ // Every exit is logged: a negotiation that declines is indistinguishable from one that never
108
+ // ran, and the two have opposite causes
109
+ if (!wants) {
110
+ if (isDebug()) {
111
+ dlog(`responder ${phase}: nobody wants it (path=${path.length}${skip === undefined ? '' : ', one skipped'})`);
112
+ }
113
+ return;
114
+ }
115
+ if (wants === currentResponder) {
116
+ if (isDebug())
117
+ dlog(`responder ${phase}: ${wants.component} already holds it`);
118
+ return;
119
+ }
120
+ if (currentResponder === undefined)
121
+ grantTo(wants, nativeEvent);
122
+ else
123
+ transferTo(currentResponder, wants, nativeEvent);
124
+ }
@@ -1,5 +1,6 @@
1
1
  import type { IMeasureInWindowOnSuccess, IMeasureLayoutOnSuccess, IMeasureOnSuccess, IRootTag } from './fabric';
2
2
  import { type ISymbioteNode } from './node';
3
+ import { type IDomRect } from './tree-host';
3
4
  export declare function registerSurfaceCommit(commit: (rootTag: IRootTag) => void, forget: (rootTag: IRootTag) => void): void;
4
5
  export declare function flushNativeProps(): void;
5
6
  export declare function requestCommitFor(node: ISymbioteNode): void;
@@ -14,6 +15,7 @@ export declare function dispatchViewCommand(node: ISymbioteNode, commandName: st
14
15
  export declare function sendAccessibilityEvent(node: ISymbioteNode, eventType: string): void;
15
16
  export declare function measure(node: ISymbioteNode, callback: IMeasureOnSuccess): void;
16
17
  export declare function measureInWindow(node: ISymbioteNode, callback: IMeasureInWindowOnSuccess): void;
18
+ export declare function getBoundingClientRect(node: ISymbioteNode, includeTransform: boolean): IDomRect | undefined;
17
19
  export declare function measureLayout(node: ISymbioteNode, relativeTo: ISymbioteNode, onSuccess: IMeasureLayoutOnSuccess, onFail?: () => void): void;
18
20
  export declare function setIsJSResponder(node: ISymbioteNode, isResponder: boolean, blockNativeResponder: boolean): void;
19
21
  /** The node's CURRENT prop value, as the host holds it. The behaviors' one read. */
@@ -7,7 +7,7 @@
7
7
  import { dlog, isDebug } from './debug.js';
8
8
  import { clearPublishedStyle, writeProp } from './node.js';
9
9
  import { flattenStyle } from './style/index.js';
10
- import { flushOps, treeHost } from './tree-host.js';
10
+ import { flushOps, treeHost, } from './tree-host.js';
11
11
  // What Fabric currently holds for `node`, or undefined before its first commit. Flushes first: an
12
12
  // imperative call can land between a mutation and its commit, and the host can't answer about ops
13
13
  // it hasn't been handed.
@@ -136,6 +136,23 @@ export function measureInWindow(node, callback) {
136
136
  }
137
137
  treeHost()?.measureInWindow(node, callback);
138
138
  }
139
+ // Synchronous, unlike the six above, mirroring nativeFabricUIManager's own shape. `undefined`
140
+ // covers "not committed", "host predates this member", and a build without react/renderer/dom
141
+ // (the native call throws there) - all read the same to a caller, like a detached element's rect.
142
+ export function getBoundingClientRect(node, includeTransform) {
143
+ const record = committedRecordOf(node);
144
+ if (record === undefined) {
145
+ dlog('getBoundingClientRect skipped: node not committed');
146
+ return undefined;
147
+ }
148
+ try {
149
+ return treeHost()?.getBoundingClientRect?.(node, includeTransform);
150
+ }
151
+ catch (error) {
152
+ dlog(`getBoundingClientRect skipped - ${error instanceof Error ? error.message : String(error)}`);
153
+ return undefined;
154
+ }
155
+ }
139
156
  // RN's public signature is (relative, onSuccess, onFail) but the native slot wants the fail
140
157
  // callback first, so the order is swapped here.
141
158
  export function measureLayout(node, relativeTo, onSuccess, onFail = () => { }) {
package/build/index.d.ts CHANGED
@@ -13,7 +13,8 @@ export type { ISymbioteNode, ISymbioteEvent, IListener } from './node';
13
13
  export { SymbioteSurface, createSurface } from './surface';
14
14
  export { setEventDispatcher } from './dispatch';
15
15
  export { setColorProcessor, processColor } from './platform-color';
16
- export { dispatchViewCommand, sendAccessibilityEvent, setNativeProps, getNativeTag, getNativeNode, whenCommitted, measure, measureInWindow, measureLayout, disposeRoot, requestCommitFor, } from './imperative';
16
+ export { dispatchViewCommand, sendAccessibilityEvent, setNativeProps, getNativeTag, getNativeNode, whenCommitted, measure, measureInWindow, measureLayout, getBoundingClientRect, disposeRoot, requestCommitFor, } from './imperative';
17
+ export type { IDomRect } from './tree-host';
17
18
  export { setTreeHost, treeHost, registerBeforeFlush, readCommitProfile, readSurfaceTelemetry, setNativeDebug, takeNativeDebugLog, } from './tree-host';
18
19
  export type { ISurfaceTelemetry } from './tree-host';
19
20
  export { nativeTreeHost, installNativeTreeHost } from './native-tree-host';
@@ -102,4 +103,6 @@ export { hideTransition, STATUS_BAR_MANAGER, ANIMATED_HIDE_TRANSITION, STATIC_HI
102
103
  export type { IStatusBarProps, IStatusBarStyle, IStatusBarAnimation, IStatusBarImperative, } from './status-bar/shared';
103
104
  export { registerHostBehavior, hasHostBehaviors, hostBehaviorFor, clearHostBehaviors, appListenerFor, addDerivedNode, SLOT_DERIVED_ALL, } from './host-behavior';
104
105
  export type { IClaimMode, IHostBehavior, IPayloadFold } from './host-behavior';
105
- export { setBehaviorListener, markPropsDirty } from './node';
106
+ export { setBehaviorListener, markPropsDirty, setNodeDispatch } from './node';
107
+ export { hasListenerFor, listenerFor } from './node';
108
+ export type { IEventDispatch } from './node';
package/build/index.js CHANGED
@@ -40,7 +40,7 @@ export { setEventDispatcher } from './dispatch.js';
40
40
  // `setColorProcessor` / `processColor` were only RE-exported by the old `commit.ts`; they point at
41
41
  // their real home now.
42
42
  export { setColorProcessor, processColor } from './platform-color';
43
- export { dispatchViewCommand, sendAccessibilityEvent, setNativeProps, getNativeTag, getNativeNode, whenCommitted, measure, measureInWindow, measureLayout, disposeRoot, requestCommitFor, } from './imperative.js';
43
+ export { dispatchViewCommand, sendAccessibilityEvent, setNativeProps, getNativeTag, getNativeNode, whenCommitted, measure, measureInWindow, measureLayout, getBoundingClientRect, disposeRoot, requestCommitFor, } from './imperative.js';
44
44
  // The tree host: the seam a runtime installs to answer about the tree JS does not hold. `setTreeHost`
45
45
  // is what `installFabric()` (@symbiote-native/test-utils) calls with the TypeScript applier.
46
46
  export { setTreeHost, treeHost, registerBeforeFlush, readCommitProfile, readSurfaceTelemetry, setNativeDebug, takeNativeDebugLog, } from './tree-host.js';
@@ -135,4 +135,7 @@ export { registerHostBehavior, hasHostBehaviors, hostBehaviorFor, clearHostBehav
135
135
  // markPropsDirty is a behavior's only way to say "the fold reads state I just changed" — every
136
136
  // other dirtying route goes through a prop write, and a derived-payload behavior has no prop to
137
137
  // write. Pair it with requestCommitFor: dirtying is not publishing.
138
- export { setBehaviorListener, markPropsDirty } from './node.js';
138
+ export { setBehaviorListener, markPropsDirty, setNodeDispatch } from './node.js';
139
+ // The only correct way to ask what would receive an event: a behavior may answer a name through
140
+ // one shared `IEventDispatch` rather than a closure in `node.listeners`
141
+ export { hasListenerFor, listenerFor } from './node.js';
@@ -20,6 +20,7 @@ export declare const KIND_ANCHOR = 2;
20
20
  export type IMutationHandle = {
21
21
  slot: number;
22
22
  slotBatch: number;
23
+ createdBatch: number;
23
24
  };
24
25
  export type IMutationBatch = {
25
26
  readonly ops: Int32Array;
@@ -38,6 +39,8 @@ export declare function noteCommitDrained(): void;
38
39
  export declare function noteHostSideChange(): void;
39
40
  /** Does the pending batch hold anything that could change what the host says this node's parent is? */
40
41
  export declare function hasPendingPlacement(handle: IMutationHandle): boolean;
42
+ /** Has the host never heard of this node, т.к. the op that creates it is still in the buffer? */
43
+ export declare function isPendingCreate(handle: IMutationHandle): boolean;
41
44
  export declare function recordCreateElement(handle: IMutationHandle, viewName: string, isText: boolean, instanceHandle: unknown): void;
42
45
  export declare function recordCreateRawText(handle: IMutationHandle, text: string): void;
43
46
  export declare function recordCreateAnchor(handle: IMutationHandle): void;
@@ -188,7 +188,7 @@ function slotOf(handle) {
188
188
  // OP_COMMIT is excluded deliberately: recording a commit is not a change to the tree, and counting
189
189
  // it would make every commit look like it had work.
190
190
  let changedSinceCommit = false;
191
- function push(op, a = 0, b = 0, c = 0, d = 0, e = 0) {
191
+ function push(op, a = 0, b = 0, c = 0) {
192
192
  if (op !== OP_COMMIT)
193
193
  changedSinceCommit = true;
194
194
  if (opCount + OP_STRIDE > ops.length) {
@@ -200,10 +200,16 @@ function push(op, a = 0, b = 0, c = 0, d = 0, e = 0) {
200
200
  ops[opCount + 1] = a;
201
201
  ops[opCount + 2] = b;
202
202
  ops[opCount + 3] = c;
203
- ops[opCount + 4] = d;
204
- ops[opCount + 5] = e;
203
+ ops[opCount + 4] = 0;
204
+ ops[opCount + 5] = 0;
205
205
  opCount += OP_STRIDE;
206
206
  }
207
+ // `OP_CREATE_ELEMENT` is the only op with a FOURTH operand, and it writes it itself rather than
208
+ // widening `push` for one call site. A rest parameter or an options bag would allocate per op,
209
+ // which is the one thing this buffer exists to avoid
210
+ function pushFourth(value) {
211
+ ops[opCount - OP_STRIDE + 4] = value;
212
+ }
207
213
  // Whether a commit would publish anything. See changedSinceCommit.
208
214
  export function hasChangedSinceCommit() {
209
215
  return changedSinceCommit;
@@ -235,21 +241,32 @@ let placementPending = new Set();
235
241
  export function hasPendingPlacement(handle) {
236
242
  return placementPending.has(handle);
237
243
  }
244
+ // A field rather than a second Set, т.к. this is read on the create path and a `Set.add` per node
245
+ // would charge the thing it saves. `createdBatch` starts at 0, which no live batch carries
246
+ function noteCreated(handle) {
247
+ placementPending.add(handle);
248
+ handle.createdBatch = batchId;
249
+ }
250
+ /** Has the host never heard of this node, т.к. the op that creates it is still in the buffer? */
251
+ export function isPendingCreate(handle) {
252
+ return handle.createdBatch === batchId;
253
+ }
238
254
  export function recordCreateElement(handle, viewName, isText, instanceHandle) {
239
255
  instanceHandles.push(instanceHandle);
240
- placementPending.add(handle);
241
- push(OP_CREATE_ELEMENT, slotOf(handle), intern(viewName), isText ? 1 : 0, instanceHandles.length - 1);
256
+ noteCreated(handle);
257
+ push(OP_CREATE_ELEMENT, slotOf(handle), intern(viewName), isText ? 1 : 0);
258
+ pushFourth(instanceHandles.length - 1);
242
259
  }
243
260
  export function recordCreateRawText(handle, text) {
244
- placementPending.add(handle);
261
+ noteCreated(handle);
245
262
  push(OP_CREATE_RAW_TEXT, slotOf(handle), intern(text));
246
263
  }
247
264
  export function recordCreateAnchor(handle) {
248
- placementPending.add(handle);
265
+ noteCreated(handle);
249
266
  push(OP_CREATE_ANCHOR, slotOf(handle));
250
267
  }
251
268
  export function recordCreateVoid(handle) {
252
- placementPending.add(handle);
269
+ noteCreated(handle);
253
270
  push(OP_CREATE_VOID, slotOf(handle));
254
271
  }
255
272
  export function recordAppendChild(parent, child) {
@@ -31,6 +31,12 @@ export type INativeEngineBindings = {
31
31
  measureInWindow: (handle: object, callback: (x: number, y: number, width: number, height: number) => void) => void;
32
32
  measureLayout: (handle: object, relativeTo: object, onFail: () => void, onSuccess: (x: number, y: number, width: number, height: number) => void) => void;
33
33
  setIsJSResponder: (handle: object, isResponder: boolean, blockNativeResponder: boolean) => void;
34
+ getBoundingClientRect?: (handle: object, includeTransform: boolean) => {
35
+ x: number;
36
+ y: number;
37
+ width: number;
38
+ height: number;
39
+ } | undefined;
34
40
  setDebugEnabled?: (enabled: boolean) => void;
35
41
  takeDebugLog?: () => readonly string[];
36
42
  readSurfaceTelemetry?: (surfaceId: number) => {
@@ -25,59 +25,38 @@ import { isRecord } from './type-guards.js';
25
25
  // restate the number: a bump that leaves a fixture behind reads as "the binary is stale", which is
26
26
  // exactly the message this constant exists to produce.
27
27
  export const SUPPORTED_NATIVE_VERSION = 4;
28
+ // A member missing from this list resolves fine at bring-up and throws at the first call site
29
+ // instead. `getBoundingClientRect` stays out on purpose: it is optional on the type.
30
+ const REQUIRED_METHOD_NAMES = [
31
+ 'allocInt32Array',
32
+ 'probeUIManager',
33
+ 'applyOps',
34
+ 'getProp',
35
+ 'getProps',
36
+ 'markPropsDirty',
37
+ 'getViewName',
38
+ 'parentOf',
39
+ 'childrenOf',
40
+ 'firstChildOf',
41
+ 'nextSiblingOf',
42
+ 'parentsOf',
43
+ 'subtreesOf',
44
+ 'teardownSubtreesOf',
45
+ 'ancestorsOf',
46
+ 'committedRecordOf',
47
+ 'dispatchCommand',
48
+ 'sendAccessibilityEvent',
49
+ 'measure',
50
+ 'measureInWindow',
51
+ 'measureLayout',
52
+ 'setIsJSResponder',
53
+ ];
28
54
  function isBindings(value) {
29
55
  if (!isRecord(value))
30
56
  return false;
31
57
  if (typeof value.version !== 'number')
32
58
  return false;
33
- if (typeof value.allocInt32Array !== 'function')
34
- return false;
35
- if (typeof value.probeUIManager !== 'function')
36
- return false;
37
- if (typeof value.applyOps !== 'function')
38
- return false;
39
- // The tree reads, checked one by one: a member added to the type without a line here resolves
40
- // fine at bring-up and throws at the first call site — a gesture or a measure() — one language
41
- // and several seconds away from the install that caused it.
42
- if (typeof value.getProp !== 'function')
43
- return false;
44
- if (typeof value.getProps !== 'function')
45
- return false;
46
- if (typeof value.markPropsDirty !== 'function')
47
- return false;
48
- if (typeof value.getViewName !== 'function')
49
- return false;
50
- if (typeof value.parentOf !== 'function')
51
- return false;
52
- if (typeof value.childrenOf !== 'function')
53
- return false;
54
- if (typeof value.firstChildOf !== 'function')
55
- return false;
56
- if (typeof value.nextSiblingOf !== 'function')
57
- return false;
58
- if (typeof value.parentsOf !== 'function')
59
- return false;
60
- if (typeof value.subtreesOf !== 'function')
61
- return false;
62
- if (typeof value.teardownSubtreesOf !== 'function')
63
- return false;
64
- if (typeof value.ancestorsOf !== 'function')
65
- return false;
66
- if (typeof value.committedRecordOf !== 'function')
67
- return false;
68
- // The imperative six, checked by name: a pod with applyOps but not these is an older binary,
69
- // and accepting it means measure() reaches a missing method at gesture time, not bring-up.
70
- if (typeof value.dispatchCommand !== 'function')
71
- return false;
72
- if (typeof value.sendAccessibilityEvent !== 'function')
73
- return false;
74
- if (typeof value.measure !== 'function')
75
- return false;
76
- if (typeof value.measureInWindow !== 'function')
77
- return false;
78
- if (typeof value.measureLayout !== 'function')
79
- return false;
80
- return typeof value.setIsJSResponder === 'function';
59
+ return REQUIRED_METHOD_NAMES.every(name => typeof value[name] === 'function');
81
60
  }
82
61
  // SUPPORTED_NATIVE_VERSION guards a memory layout, not a member list: the shape guard above
83
62
  // already refuses an older pod lacking a new function's name, so bumping for that alone would
@@ -36,6 +36,7 @@ export function nativeTreeHost(bindings) {
36
36
  measureInWindow: bindings.measureInWindow,
37
37
  measureLayout: bindings.measureLayout,
38
38
  setIsJSResponder: bindings.setIsJSResponder,
39
+ getBoundingClientRect: bindings.getBoundingClientRect,
39
40
  };
40
41
  }
41
42
  // Install it, if this runtime has a native module and nothing has claimed the seam already.
@@ -0,0 +1,11 @@
1
+ import type { IEventDispatch, IListener, ISymbioteNode } from './node-types';
2
+ export declare function setBehaviorListener(node: ISymbioteNode, name: string, listener: IListener | undefined): void;
3
+ export declare function setNodeDispatch(node: ISymbioteNode, dispatch: IEventDispatch | undefined): void;
4
+ /** Whether anything at all would receive `name` on this node. */
5
+ export declare function hasListenerFor(node: ISymbioteNode, name: string): boolean;
6
+ export declare function listenerFor(node: ISymbioteNode, name: string): IListener | undefined;
7
+ export declare function setEventListener(node: ISymbioteNode, name: string, value: unknown): void;
8
+ export declare function isOnEventName(key: string): boolean;
9
+ /** `onChange` to `change`. */
10
+ export declare function listenerName(propName: string): string;
11
+ export declare const RESPONDER_EVENTS: ReadonlySet<string>;