@symbiote-native/engine 0.3.0 → 0.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,262 @@
1
+ // Per-TAG behavior on an engine node — the seam that lets a primitive's state machine live BELOW
2
+ // the framework instead of inside a framework component.
3
+ //
4
+ // WHY IT EXISTS. Vue, Svelte, Solid and Angular all optimize element subtrees and stop at a
5
+ // component boundary, so a primitive shipped as a component is charged a per-instance price in
6
+ // each framework's own currency (an instance, a props Proxy, anchor nodes, an LView). A primitive
7
+ // whose state the TEMPLATE never reads does not need to be a component at all — its machine only
8
+ // needs a per-node home, and the engine node is one. `.claude/rules/host-primitive-tier.md` has
9
+ // the tier model; this module is the tier-2 half of it.
10
+ //
11
+ // WHY A REGISTRY RATHER THAN A DIRECT IMPORT. `@symbiote-native/components` depends on
12
+ // `@symbiote-native/engine`, never the reverse, so the engine cannot import `createPressHandlers`
13
+ // and friends. CLAUDE.md's preferred answer to a registration problem — delete the indirection —
14
+ // is therefore unavailable here; the inversion is forced, not chosen.
15
+ //
16
+ // WHICH MEANS THE REGISTRATION ITSELF IS THE HAZARD, and it is the one CLAUDE.md spells out:
17
+ // Metro turns on `inlineRequires` for production only, moving a `require` down to the first place
18
+ // its binding is used as a VALUE, and a barrel's `export { X } from './x'` compiles to a lazy
19
+ // getter. A module whose only job is to call `registerHostBehavior` is never named as a value, so
20
+ // re-exporting it from a barrel means it NEVER EVALUATES in a release build — dev is perfect,
21
+ // release silently has no behavior. The one shape that works is a bare side-effect import that is
22
+ // never re-exported from that barrel, the pattern in
23
+ // `packages/slider/src/{react,vue,svelte,angular}/index.ts`:
24
+ //
25
+ // import '../register'; // in the adapter entry — NOT `export * from '../register'`
26
+ //
27
+ // `registerHostBehavior` emits a `dlog` precisely so `DEBUG=1` answers "did my registration run at
28
+ // all" before anyone starts debugging the behavior itself.
29
+ import { dlog } from './debug.js';
30
+ const behaviors = new Map();
31
+ // Nodes that `removeChild` unlinked and that may or may not be coming back. See
32
+ // `sweepDetachedBehaviors` for why the answer is not known until commit.
33
+ const detachCandidates = new Set();
34
+ // Nodes the sweep has torn down. A torn-down node can still be re-inserted — see
35
+ // `reattachHostBehaviors` — and this is what tells an insert whether it must walk at all, so the
36
+ // common case (building a fresh tree) never walks anything.
37
+ const tornDown = new WeakSet();
38
+ // The behavior a node actually got, remembered from its one and only registry lookup.
39
+ //
40
+ // THE REGISTRY IS KEYED BY INTRINSIC TAG AND THE NODE IS NOT. `node.component` is the FABRIC view
41
+ // name: every adapter resolves the tag through `descriptorFor` before calling `createElement`, so
42
+ // `symbiote-view` arrives as `RCTView`. Keying the registry by Fabric name instead is not an
43
+ // option — a pressable resolves to `RCTView` like any other view, so the press machine would
44
+ // attach to every plain `View` in the app. So the tag alphabet is used EXACTLY ONCE, at
45
+ // `attachHostBehavior`, where the caller still holds it; every later lookup reads this map.
46
+ //
47
+ // Found by a peer session probing the installed shape, not by a unit test: the tests built their
48
+ // subject with `createElement(PRESSABLE_TAG)`, which passes the tag AS the Fabric name and makes
49
+ // the key match by accident. No adapter constructs a node that way, so the registration could
50
+ // never have fired in an app while all six break-tests kept failing correctly on their own axes.
51
+ const attached = new WeakMap();
52
+ // The gate. `createElement` and `removeChild` are the two hottest paths in the engine (9 002 and
53
+ // ~1 000 calls on one benchmark row set), so neither may pay a Set insert for a feature no app
54
+ // uses yet. While this is false both paths cost one boolean read, the same discipline as `isDebug`.
55
+ let hasBehaviors = false;
56
+ export function registerHostBehavior(component, behavior) {
57
+ dlog(`registerHostBehavior: ${component}`);
58
+ behaviors.set(component, behavior);
59
+ hasBehaviors = true;
60
+ }
61
+ // Read access to the registry, so an audit can DERIVE what a behavior owns instead of restating it.
62
+ // The one that matters: a name in `ownedListeners` is only reachable if `routeProp` also treats it
63
+ // as a registered event — otherwise the app's callback lands in `node.props` and the machine, which
64
+ // reads the stash, never sees it. That set difference is a test
65
+ // (`core/components/src/behaviors/owned-listeners-are-routable.test.ts`) and it needs this to stay
66
+ // derived rather than becoming another hand-written list.
67
+ export function hostBehaviorFor(tag) {
68
+ return behaviors.get(tag);
69
+ }
70
+ export function hasHostBehaviors() {
71
+ return hasBehaviors;
72
+ }
73
+ // The app's listeners for names a behavior owns, per node. Not on the node: this exists only for
74
+ // nodes carrying a behavior, and adding a field for it would pay a shape transition on every node
75
+ // in every app for a feature almost none of them use.
76
+ const stashed = new WeakMap();
77
+ // Takes the NODE, not a component string: the caller (`setEventListener`) has only the Fabric name
78
+ // by then, which is not the registry's alphabet. Reads the same map `attachHostBehavior` wrote.
79
+ export function ownsListener(node, name) {
80
+ return attached.get(node)?.ownedListeners?.includes(name) === true;
81
+ }
82
+ export function stashAppListener(node, name, listener) {
83
+ let bag = stashed.get(node);
84
+ if (bag === undefined) {
85
+ bag = new Map();
86
+ stashed.set(node, bag);
87
+ }
88
+ if (listener === undefined)
89
+ bag.delete(name);
90
+ else
91
+ bag.set(name, listener);
92
+ }
93
+ // What the app wrote for an owned event name — the behavior's OUTPUT target. Undefined when the
94
+ // app wired nothing, which is an ordinary case, not an error.
95
+ export function appListenerFor(node, name) {
96
+ return stashed.get(node)?.get(name);
97
+ }
98
+ // `tag` is the INTRINSIC tag the adapter started from, not the resolved Fabric name it put on the
99
+ // node. Defaulted to `node.component` so an adapter that has not been taught to pass it keeps
100
+ // working for a behavior registered under a Fabric name — no adapter registers one, so in practice
101
+ // the default simply never matches and costs one failed lookup.
102
+ export function attachHostBehavior(node, tag) {
103
+ const behavior = behaviors.get(tag);
104
+ if (behavior === undefined)
105
+ return;
106
+ attached.set(node, behavior);
107
+ // A field rather than a lookup at payload-build time: `fabricProps` runs per node per commit and
108
+ // must not pay a Map probe to discover that almost nothing has a fold.
109
+ node.payloadFold = behavior.foldPayload;
110
+ behavior.attach(node);
111
+ if (behavior.attachAfterCommit !== undefined)
112
+ awaitingCommit.add(node);
113
+ if (behavior.afterCommit !== undefined)
114
+ committedEachTime.add(node);
115
+ }
116
+ // Nodes whose behavior declared `afterCommit`. Separate from `awaitingCommit` because the two have
117
+ // opposite lifetimes: one empties as its nodes commit, this one holds until teardown.
118
+ const committedEachTime = new Set();
119
+ // Nodes whose behavior declared `attachAfterCommit` and whose first commit has not happened yet.
120
+ //
121
+ // A plain Set rather than a call into `whenCommitted`: `commit.ts` already imports this module, so
122
+ // reaching back for it would close an import cycle. Metro's `inlineRequires` has made module
123
+ // evaluation order a real hazard here rather than a theoretical one (see this file's own
124
+ // registration comment), so the dependency stays one-directional and commit DRAINS this instead.
125
+ const awaitingCommit = new Set();
126
+ /**
127
+ * Run the deferred half of every behavior whose node has now been committed. Called from the commit
128
+ * path immediately after `completeRoot`, where fresh Fabric tags have just been assigned.
129
+ *
130
+ * `isCommitted` is passed in for the same no-cycle reason — `committedOf` lives in `commit.ts`. A
131
+ * still-uncommitted node stays in the set: a create superseded before it ever reached Fabric waits
132
+ * for the commit that lands it, and `detachSubtree` drops it if that commit never comes.
133
+ */
134
+ export function runDeferredAttaches(isCommitted) {
135
+ // The gate: an app registering no behavior pays two Set-size reads per commit, matching the
136
+ // discipline `hasBehaviors` sets for `createElement`.
137
+ if (awaitingCommit.size === 0 && committedEachTime.size === 0)
138
+ return;
139
+ // SETUP BEFORE THE RECURRING BEAT, and the order is load-bearing on the FIRST commit, where a
140
+ // node carrying both hooks is drained by both. `attachAfterCommit` is where a behavior seeds the
141
+ // mirrors that `afterCommit` then compares against; run them the other way round and the first
142
+ // beat compares against nothing and commands a redundant write down to native.
143
+ for (const node of awaitingCommit) {
144
+ if (!isCommitted(node))
145
+ continue;
146
+ awaitingCommit.delete(node);
147
+ attached.get(node)?.attachAfterCommit?.(node);
148
+ }
149
+ for (const node of committedEachTime) {
150
+ if (!isCommitted(node))
151
+ continue;
152
+ attached.get(node)?.afterCommit?.(node);
153
+ }
154
+ }
155
+ // `removeChild` is NOT the destroy signal, and reading it as one is the bug this indirection
156
+ // exists to avoid. Engine-side it looks like one — a reorder goes through `detach` inside
157
+ // appendChild/insertBefore and never lands in removeChild — but a FRAMEWORK can spell a move as
158
+ // remove-then-reinsert. Solid does, in `solid-js/universal`: `replaceNode` (universal.cjs:186) is
159
+ // `insertNode` + `removeNode`, and `reconcileArrays` calls it at :157 for a node that IS in the
160
+ // new array and is needed at a later index. Its sibling call at :130 is guarded by
161
+ // `if (!map || !map.has(a[aStart]))` and removes only genuinely absent nodes — one guarded call
162
+ // and one not, which is why a quick read of that file says "removeChild means gone".
163
+ //
164
+ // Tearing down there would kill the machine of a node that returns alive a few operations later,
165
+ // in the same batch: long-press silently stops working after certain list reorders, on device
166
+ // only, with nothing red. So removal only nominates.
167
+ export function markDetachCandidate(node) {
168
+ detachCandidates.add(node);
169
+ }
170
+ // Commit is where a removal is CHEAPEST to distinguish from a move — a node unlinked and
171
+ // reinserted before the commit is back in the tree by now, which covers Solid's replaceNode. It is
172
+ // NOT a proof of death, and the earlier version of this comment claimed it was. Svelte parks LIVE
173
+ // nodes offscreen across commits and sometimes across seconds: `detachFromParent`
174
+ // (adapters/svelte/src/dom-shim/shim-node.ts) moves a node into a DocumentFragment that has no
175
+ // engine node, calls engineRemoveChild AND requestCommit, and Svelte fully intends to bring it
176
+ // back — from a parked `{#if}` branch, from `each.js`'s destroy_effects, and worst, from
177
+ // boundary.js's move_effect while a pending snippet shows, which returns when async work resolves.
178
+ // So a sweep can and does tear down a node that comes back, which is why `attach` is re-runnable
179
+ // (reattachHostBehaviors) rather than why the sweep tries to be cleverer. The machine RESTARTS on
180
+ // re-insert instead of surviving an arbitrary absence; a parked subtree is offscreen, so nobody is
181
+ // mid-gesture in it, and teardown staying unconditional means there is no leak mode.
182
+ //
183
+ // The subtree walk lives here rather than at removal, and is cheaper for it: only the nodes that
184
+ // actually left are walked.
185
+ export function sweepDetachedBehaviors(topLevel) {
186
+ if (detachCandidates.size === 0)
187
+ return;
188
+ // A surface's top-level nodes carry `parent === undefined` by design (surface.ts), and
189
+ // `commitChildren` re-lists them without going through appendChild — so for those the parent
190
+ // check alone would report a live node as gone.
191
+ const seen = new Set();
192
+ for (const node of detachCandidates) {
193
+ if (node.parent !== undefined || topLevel.includes(node))
194
+ continue;
195
+ detachSubtree(node, seen);
196
+ }
197
+ detachCandidates.clear();
198
+ }
199
+ // `seen` guards the one overlap the candidate set can contain: a removed parent and a removed
200
+ // descendant of it are both nominated, and without it the descendant is detached twice.
201
+ function detachSubtree(node, seen) {
202
+ if (seen.has(node))
203
+ return;
204
+ seen.add(node);
205
+ // Marked whether or not THIS node carries a behavior: the mark is what tells a later insert to
206
+ // walk, and the node re-inserted is usually a plain container whose DESCENDANT holds the
207
+ // machine. Gating the mark on `behaviors.has` made the row wrapper unmarked and the whole walk
208
+ // skip — the first version of the parked-node test caught exactly that.
209
+ tornDown.add(node);
210
+ // Drop a deferral the node never got to run. NO TEST CAN SEE THIS, and it is kept anyway —
211
+ // stated rather than left as apparent coverage. The `isCommitted` predicate in the drain already
212
+ // stops such a node from firing, so removing this line changes no observable behaviour; what it
213
+ // changes is that a node created and torn down inside one tick stays in the Set forever, holding
214
+ // a strong reference to a dead subtree. A leak, not a wrong result, and this file's break-test
215
+ // discipline correctly reports it as unfalsifiable.
216
+ awaitingCommit.delete(node);
217
+ // The recurring hook stops with the node, and unlike the deferral above this one has a visible
218
+ // consequence if forgotten: a torn-down node would keep being asked to reconcile props against a
219
+ // subtree that has left the tree, on every commit, forever.
220
+ // The recurring hook stops with the node, and unlike the deferral above this one has a visible
221
+ // consequence if forgotten: a torn-down node would keep being asked to reconcile props against a
222
+ // subtree that has left the tree, on every commit, forever.
223
+ committedEachTime.delete(node);
224
+ // The map, not the registry: by here only the Fabric name is left on the node.
225
+ attached.get(node)?.detach(node);
226
+ for (const child of node.children)
227
+ detachSubtree(child, seen);
228
+ }
229
+ // Re-arms a node the sweep tore down but that the framework put back. Called from appendChild and
230
+ // insertBefore, and it is a WeakSet miss — no walk at all — for every node in a freshly built
231
+ // tree, which is the path that runs ~9 000 times per benchmark create.
232
+ export function reattachHostBehaviors(node) {
233
+ if (!tornDown.has(node))
234
+ return;
235
+ reattachSubtree(node);
236
+ }
237
+ function reattachSubtree(node) {
238
+ if (tornDown.has(node)) {
239
+ tornDown.delete(node);
240
+ const behavior = attached.get(node);
241
+ behavior?.attach(node);
242
+ // Re-arm the deferred half too. A parked node usually returns with its tag intact, so this
243
+ // fires on the next drain — but re-arming is what keeps `attach` and `attachAfterCommit` a
244
+ // PAIR. Restore only one and a behavior that splits its setup across the two comes back
245
+ // half-initialised, which is the failure this seam exists to prevent.
246
+ if (behavior?.attachAfterCommit !== undefined)
247
+ awaitingCommit.add(node);
248
+ if (behavior?.afterCommit !== undefined)
249
+ committedEachTime.add(node);
250
+ }
251
+ for (const child of node.children)
252
+ reattachSubtree(child);
253
+ }
254
+ // Test-only. A registry is module state, so a suite that registers a behavior leaks it into every
255
+ // later test in the same file unless it is cleared.
256
+ export function clearHostBehaviors() {
257
+ behaviors.clear();
258
+ detachCandidates.clear();
259
+ awaitingCommit.clear();
260
+ committedEachTime.clear();
261
+ hasBehaviors = false;
262
+ }
@@ -1,11 +1,3 @@
1
- import { type ISymbioteNode } from '../node';
2
- import type { IMeasureOnSuccess, IMeasureInWindowOnSuccess, IMeasureLayoutOnSuccess } from '../fabric';
3
- export interface IHostInstance extends ISymbioteNode {
4
- measure(callback: IMeasureOnSuccess): void;
5
- measureInWindow(callback: IMeasureInWindowOnSuccess): void;
6
- measureLayout(relativeToNativeNode: IHostInstance | number, onSuccess: IMeasureLayoutOnSuccess, onFail?: () => void): void;
7
- setNativeProps(nativeProps: Record<string, unknown>): void;
8
- focus(): void;
9
- blur(): void;
10
- }
1
+ import type { ISymbioteNode } from '../node';
2
+ export type IHostInstance = ISymbioteNode;
11
3
  export declare function toPublicInstance(node: ISymbioteNode): IHostInstance;
@@ -1,49 +1,16 @@
1
- // The public instance a host ref hands back, RN's ReactFabricHostComponent. toPublicInstance
2
- // augments the retained node with the imperative API libraries reach through (reanimated,
3
- // gesture-handler, react-navigation): measure / measureInWindow / measureLayout /
4
- // setNativeProps / focus / blur. The methods are attached onto the node object itself (not its
5
- // props, so they never reach Fabric); each resolves the node's CURRENT committed handle at call
6
- // time through the engine, so a clone-on-write commit between calls is transparent.
1
+ // The public instance a host ref hands back, RN's ReactFabricHostComponent: measure /
2
+ // measureInWindow / measureLayout / setNativeProps / focus / blur, the imperative API libraries
3
+ // reach through (reanimated, gesture-handler, react-navigation).
7
4
  //
8
- // This lives in the engine, not an adapter: it depends only on engine internals (the commit
9
- // free functions + the retained node), so every adapter inherits the SAME public instance for
10
- // free — React's getPublicInstance and the Vue renderer both graft this onto their host nodes.
11
- import { measure as engineMeasure, measureInWindow as engineMeasureInWindow, measureLayout as engineMeasureLayout, setNativeProps as engineSetNativeProps, dispatchViewCommand, } from '../commit.js';
12
- import { isSymbioteNode } from '../node.js';
13
- import { dlog } from '../debug.js';
14
- const FOCUS_COMMAND = 'focus';
15
- const BLUR_COMMAND = 'blur';
16
- function isHostInstance(node) {
17
- return typeof Reflect.get(node, 'measure') === 'function';
18
- }
19
- // Augment the retained node with the public-instance methods, once. The same node
20
- // instance persists across commits, so attaching once is enough, every method reads
21
- // the live handle through the engine on each call.
5
+ // EVERY retained node already carries it — the six methods live on the shared node prototype
6
+ // (`SymbioteNode` in ../node), so there is nothing left to graft and `toPublicInstance` is the
7
+ // identity. It used to Object.assign six fresh closures onto each node; that cost 54 000 closures
8
+ // per 1 000-row create and made GC the largest bucket in the profile. The rationale, and why
9
+ // React was the one adapter not paying it, is recorded on ISymbioteNode.
10
+ //
11
+ // The function and the type both stay: they are the seam every adapter names (React's
12
+ // getPublicInstance, Vue's nodeOps createElement, Angular's Renderer2, Svelte's dom-shim), and a
13
+ // call site saying "hand me the public instance" still reads correctly at a no-op.
22
14
  export function toPublicInstance(node) {
23
- if (isHostInstance(node))
24
- return node;
25
- return Object.assign(node, {
26
- measure(callback) {
27
- engineMeasure(node, callback);
28
- },
29
- measureInWindow(callback) {
30
- engineMeasureInWindow(node, callback);
31
- },
32
- measureLayout(relativeToNativeNode, onSuccess, onFail) {
33
- if (!isSymbioteNode(relativeToNativeNode)) {
34
- dlog('measureLayout: relative target must be a host ref');
35
- return;
36
- }
37
- engineMeasureLayout(node, relativeToNativeNode, onSuccess, onFail);
38
- },
39
- setNativeProps(nativeProps) {
40
- engineSetNativeProps(node, nativeProps);
41
- },
42
- focus() {
43
- dispatchViewCommand(node, FOCUS_COMMAND, []);
44
- },
45
- blur() {
46
- dispatchViewCommand(node, BLUR_COMMAND, []);
47
- },
48
- });
15
+ return node;
49
16
  }
package/build/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { createElement, createRawText, createAnchor, isAnchor, appendChild, insertBefore, removeChild, setProp, setEventListener, routeProp, censusRetainedTree, getExplicitStyle, setNodeHidden, setText, isSymbioteNode, isSymbioteEvent, RAW_TEXT_COMPONENT, debugNodeId, } from './node';
1
+ export { createElement, createRawText, createAnchor, isAnchor, appendChild, insertBefore, removeChild, setProp, setEventListener, routeProp, censusRetainedTree, getExplicitStyle, setNodeHidden, setNodeComponent, setNodePressed, setText, isSymbioteNode, isSymbioteEvent, RAW_TEXT_COMPONENT, debugNodeId, } from './node';
2
2
  export { isEventFor } from './view-config';
3
3
  export { registerComponent, setNativeViewConfigSource } from './registry';
4
4
  export { isRecord } from './type-guards';
@@ -13,6 +13,7 @@ export { setEventDispatcher } from './dispatch';
13
13
  export { setColorProcessor, processColor, dispatchViewCommand, sendAccessibilityEvent, setNativeProps, getNativeTag, getNativeNode, whenCommitted, measure, measureInWindow, measureLayout, disposeRoot, readCommitProfile, } from './commit';
14
14
  export type { ICommitProfile } from './commit';
15
15
  export { registerPostCommit, unregisterPostCommit } from './post-commit';
16
+ export { foldAriaProps } from './accessibility-props';
16
17
  export { toPublicInstance } from './host-instance';
17
18
  export type { IHostInstance } from './host-instance';
18
19
  export { PlatformColor, DynamicColorIOS, isOpaqueColorValue, } from './platform-color';
@@ -89,3 +90,7 @@ export type { IAccessibilityChangeEvent, IAccessibilityChangeEventName, IAccessi
89
90
  export { applyStatusBarProps, statusBarImperative, statusBarCurrentHeight, } from './status-bar';
90
91
  export { hideTransition, STATUS_BAR_MANAGER, ANIMATED_HIDE_TRANSITION, STATIC_HIDE_TRANSITION, } from './status-bar/shared';
91
92
  export type { IStatusBarProps, IStatusBarStyle, IStatusBarAnimation, IStatusBarImperative, } from './status-bar/shared';
93
+ export { registerHostBehavior, hasHostBehaviors, hostBehaviorFor, clearHostBehaviors, appListenerFor, } from './host-behavior';
94
+ export type { IHostBehavior } from './host-behavior';
95
+ export { requestCommitFor } from './commit';
96
+ export { setBehaviorListener } from './node';
package/build/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // Every framework adapter drives this tiny mutation API; all Fabric-specific
3
3
  // logic (tag allocation, view-name resolution, clone-on-write, event
4
4
  // normalization) lives behind it, in one place.
5
- export { createElement, createRawText, createAnchor, isAnchor, appendChild, insertBefore, removeChild, setProp, setEventListener, routeProp, censusRetainedTree, getExplicitStyle, setNodeHidden, setText, isSymbioteNode, isSymbioteEvent, RAW_TEXT_COMPONENT, debugNodeId, } from './node.js';
5
+ export { createElement, createRawText, createAnchor, isAnchor, appendChild, insertBefore, removeChild, setProp, setEventListener, routeProp, censusRetainedTree, getExplicitStyle, setNodeHidden, setNodeComponent, setNodePressed, setText, isSymbioteNode, isSymbioteEvent, RAW_TEXT_COMPONENT, debugNodeId, } from './node.js';
6
6
  export { isEventFor } from './view-config.js';
7
7
  export { registerComponent, setNativeViewConfigSource } from './registry.js';
8
8
  // Real cross-package consumer: core/components' KeyboardAvoidingView render narrows
@@ -24,9 +24,13 @@ export { setColorProcessor, processColor, dispatchViewCommand, sendAccessibility
24
24
  // across adapters has to hang off this, not off a per-framework lifecycle hook, or it measures a
25
25
  // different quantity in each one under the same name.
26
26
  export { registerPostCommit, unregisterPostCommit } from './post-commit.js';
27
- // The public instance grafted onto a host node by every adapter (React's getPublicInstance,
28
- // the Vue renderer's createElement): the imperative measure/setNativeProps/focus API. Lives
29
- // here because it depends only on engine internals, so all adapters inherit it identically.
27
+ // The aria/role -> accessibility* fold. Lives here rather than in a component wrapper because a
28
+ // LOWERED element has no wrapper: `fabricProps` runs it on the way to the payload, so every path
29
+ // gets it. `core/components`' typed `resolveAccessibilityProps` delegates to this one.
30
+ export { foldAriaProps } from './accessibility-props.js';
31
+ // The public instance every host node already is (React's getPublicInstance, the Vue renderer's
32
+ // createElement): the imperative measure/setNativeProps/focus API, on the shared node prototype.
33
+ // toPublicInstance is the identity that names the seam — see ./host-instance.
30
34
  export { toPublicInstance } from './host-instance/index.js';
31
35
  export { PlatformColor, DynamicColorIOS, isOpaqueColorValue, } from './platform-color/index.js';
32
36
  // CSS-style processors (boxShadow/filter): RN parses these in JS before native because
@@ -87,3 +91,6 @@ export { AccessibilityInfo } from './accessibility-info';
87
91
  // .ios re-export would otherwise duplicate-export the type symbols).
88
92
  export { applyStatusBarProps, statusBarImperative, statusBarCurrentHeight, } from './status-bar';
89
93
  export { hideTransition, STATUS_BAR_MANAGER, ANIMATED_HIDE_TRANSITION, STATIC_HIDE_TRANSITION, } from './status-bar/shared.js';
94
+ export { registerHostBehavior, hasHostBehaviors, hostBehaviorFor, clearHostBehaviors, appListenerFor, } from './host-behavior.js';
95
+ export { requestCommitFor } from './commit.js';
96
+ export { setBehaviorListener } from './node.js';
package/build/node.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import type { IFabricNode, IFabricProps, IRootTag, IMeasureOnSuccess, IMeasureInWindowOnSuccess, IMeasureLayoutOnSuccess } from './fabric';
2
+ import { type IClassNameValue } from './style-registry';
3
+ import { type IPayloadFold } from './host-behavior';
1
4
  declare const BRAND: unique symbol;
2
5
  export declare const RAW_TEXT_COMPONENT = "RCTRawText";
3
6
  export declare const TEXT_COMPONENT = "RCTText";
@@ -13,15 +16,57 @@ export type IListener = (event: ISymbioteEvent) => unknown;
13
16
  export declare function isSymbioteEvent(value: unknown): value is ISymbioteEvent;
14
17
  export interface ISymbioteNode {
15
18
  readonly [BRAND]: true;
16
- readonly component: string;
19
+ component: string;
17
20
  readonly isText: boolean;
18
21
  props: Record<string, unknown>;
19
22
  listeners: Map<string, IListener> | undefined;
20
23
  children: ISymbioteNode[];
21
24
  parent: ISymbioteNode | undefined;
22
25
  dirty: boolean;
26
+ propsDirty: boolean;
27
+ hasAriaAlias: boolean;
28
+ payloadFold: IPayloadFold | undefined;
29
+ structureDirty: boolean;
30
+ committed: IMirror | undefined;
31
+ styleParts: IClassStyleParts | undefined;
32
+ measure(callback: IMeasureOnSuccess): void;
33
+ measureInWindow(callback: IMeasureInWindowOnSuccess): void;
34
+ measureLayout(relativeToNativeNode: ISymbioteNode | number, onSuccess: IMeasureLayoutOnSuccess, onFail?: () => void): void;
35
+ setNativeProps(nativeProps: Record<string, unknown>): void;
36
+ focus(): void;
37
+ blur(): void;
23
38
  }
24
- export declare function createElement(component: string, isText?: boolean): ISymbioteNode;
39
+ export interface IMirror {
40
+ handle: IFabricNode;
41
+ tag: number;
42
+ rootTag: IRootTag;
43
+ props: IFabricProps;
44
+ children: readonly ISymbioteNode[];
45
+ viewName: string;
46
+ parent: ISymbioteNode | undefined;
47
+ owner: ISymbioteNode;
48
+ }
49
+ /**
50
+ * The committed record for `node`, or `undefined` if it has never been committed - or if `node` is
51
+ * not the raw retained node at all.
52
+ *
53
+ * That second case is the reason this is a function rather than a bare `node.committed` read. The
54
+ * engine identifies a node BY IDENTITY, and the classic way to break that is to hand the engine a
55
+ * wrapper instead of the node: a Vue `reactive()`/deep-`ref()` Proxy around a host element is the
56
+ * one that actually happens (see the vue-adapter-reactivity skill; `shallowRef` is the fix).
57
+ *
58
+ * The old WeakMap caught this for free - a Proxy is a different object, so `mirror.get(proxy)` missed
59
+ * and every imperative API bailed with a clear "node not committed". A plain property read does NOT:
60
+ * a Proxy forwards `proxy.committed` straight to the target and hands back a real record, whose
61
+ * `handle` Vue would then deep-wrap on the way out. That handle is a JSI host object; a Proxy around
62
+ * it reaches `cloneNodeWithNewProps` and fails somewhere deep in native, far from the cause.
63
+ *
64
+ * So the identity check that was implicit in the WeakMap is explicit here: a record written on the
65
+ * raw node names it, and `record.owner !== node` means whatever we were handed is not that node.
66
+ * One reference comparison, and the wrap now fails LOUDER than it used to rather than quieter.
67
+ */
68
+ export declare function committedOf(node: ISymbioteNode): IMirror | undefined;
69
+ export declare function createElement(component: string, isText?: boolean, tag?: string): ISymbioteNode;
25
70
  export declare function createRawText(text: string): ISymbioteNode;
26
71
  export declare function isSymbioteNode(value: unknown): value is ISymbioteNode;
27
72
  export declare function debugNodeId(node: ISymbioteNode): number;
@@ -29,13 +74,48 @@ export declare const ANCHOR_COMPONENT = "#anchor";
29
74
  export declare function createAnchor(): ISymbioteNode;
30
75
  export declare function isAnchor(node: ISymbioteNode): boolean;
31
76
  export declare function isEmptyRawText(node: ISymbioteNode): boolean;
77
+ /**
78
+ * Change which Fabric view a node commits as, keeping the node's identity.
79
+ *
80
+ * The commit walk already re-creates a node whose `viewName` no longer matches its committed one —
81
+ * that is how a `<Text>` moving in or out of another `<Text>` flips between RCTText and
82
+ * RCTVirtualText (`commit.ts`, reason `view-kind`). This exposes the same door for a prop-driven
83
+ * view choice, so `intrinsicWhen` is honoured on UPDATE and not only at create.
84
+ *
85
+ * The POLICY stays out of the engine: which prop decides, and which view it decides between, lives
86
+ * in `HOST_PRIMITIVES` and is read by `resolveIntrinsicTag` in `@symbiote-native/components`. The
87
+ * engine only knows how to swap the name — the same split every other spec-driven fold has here.
88
+ *
89
+ * A no-op when the name is unchanged, so a renderer may call it on every update without comparing
90
+ * first.
91
+ */
92
+ export declare function setNodeComponent(node: ISymbioteNode, component: string): void;
32
93
  export declare function markDirty(node: ISymbioteNode): void;
94
+ export declare function markPropsDirty(node: ISymbioteNode): void;
95
+ export declare function markStructureDirty(parent: ISymbioteNode): void;
33
96
  export declare function takePropStats(): {
34
97
  writes: number;
35
98
  noops: number;
36
99
  };
37
100
  export declare function setProp(node: ISymbioteNode, key: string, value: unknown): void;
101
+ /**
102
+ * Install a listener the BEHAVIOR owns, bypassing the ownership check.
103
+ *
104
+ * `setEventListener` diverts an owned name into the stash, which is right for an app listener and
105
+ * circular for the behavior's own dispatcher — it would stash itself and never occupy the slot it
106
+ * exists to hold. This is the one writer allowed past that gate.
107
+ */
108
+ export declare function setBehaviorListener(node: ISymbioteNode, name: string, listener: IListener): void;
38
109
  export declare function setEventListener(node: ISymbioteNode, name: string, value: unknown): void;
110
+ export interface IClassStyleParts {
111
+ classStyle: unknown;
112
+ explicitStyle: unknown;
113
+ hiddenStyle: unknown;
114
+ className: IClassNameValue | undefined;
115
+ isPressed: boolean;
116
+ activeStyle: unknown;
117
+ activeStyleFromCallback: boolean;
118
+ }
39
119
  /**
40
120
  * Stop a node painting without unmounting it, or let it paint again.
41
121
  *
@@ -44,6 +124,20 @@ export declare function setEventListener(node: ISymbioteNode, name: string, valu
44
124
  * author's style byte for byte — belongs to whoever owns the style merge, and that is here.
45
125
  */
46
126
  export declare function setNodeHidden(node: ISymbioteNode, hidden: boolean): void;
127
+ /**
128
+ * Put a node into (or out of) its pressed state, so `.x:active` rules apply.
129
+ *
130
+ * The engine-owned half of what `:active` is on the web: the press state resolves BELOW the
131
+ * framework and never crosses into it, which is what lets a pressable be an intrinsic tag rather
132
+ * than a component (`.claude/rules/host-primitive-tier.md`, tier 2). A component is forced only
133
+ * when the TEMPLATE must read the state — `v-slot="{ pressed }"` and the function form of `style`
134
+ * — and this exists so the common case does not have to.
135
+ *
136
+ * Costs nothing when no `:active` rule is registered anywhere: `resolveActiveClassName` hands back
137
+ * the very same object the unpressed path returns, so `isAlreadyPublished` turns the re-push away
138
+ * and the node is never dirtied.
139
+ */
140
+ export declare function setNodePressed(node: ISymbioteNode, pressed: boolean): void;
47
141
  export declare function getExplicitStyle(node: ISymbioteNode): unknown;
48
142
  export declare function routeProp(node: ISymbioteNode, key: string, value: unknown): void;
49
143
  export declare function setText(node: ISymbioteNode, text: string): void;