@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.
package/README.md CHANGED
@@ -97,8 +97,14 @@ before a tag is guaranteed to exist.
97
97
  - **Runtime modules**, framework-agnostic, re-exported by every adapter: `Platform`,
98
98
  `StyleSheet` (+ `computeHairlineWidth`), `Dimensions`, `PixelRatio`, `Appearance`, `AppState`,
99
99
  `Keyboard`, `AccessibilityInfo`, `BackHandler`, `PermissionsAndroid`, `LayoutAnimation`,
100
- `InteractionManager`, `PanResponder`, and the imperative modules `Alert`, `Share`,
100
+ `InteractionManager`, `PanResponder`, `StatusBar`, and the imperative modules `Alert`, `Share`,
101
101
  `ActionSheetIOS`, `Linking`, `Vibration`, `ToastAndroid`, `Settings`, `I18nManager`.
102
+ - **Host behaviors** (`registerHostBehavior` / `IHostBehavior` / `hasHostBehaviors` /
103
+ `clearHostBehaviors` / `appListenerFor` / `setBehaviorListener` / `requestCommitFor`) — the
104
+ registry that lets a primitive's state machine live directly on the engine node instead of
105
+ inside a framework component, so a `Pressable`/`Switch`/`TextInput`/`Image` can compile to a
106
+ bare intrinsic tag ("host-primitive lowering"). `@symbiote-native/components` registers its
107
+ behaviors against this seam; the engine never imports them back.
102
108
  - **`Animated`** — both the JS and native driver (`timing` / `spring` / `decay` / `loop` /
103
109
  `ValueXY` / tracking / `diffClamp` / `Easing`), including the native-event attachment path
104
110
  (`attachNativeEvent`, `AnimatedEvent`).
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Whether one key is an alias this fold consumes. `startsWith` rather than a Set lookup: this runs
3
+ * on `setProp`, the hottest write path in the engine (32 001 writes on one benchmark create), and
4
+ * it is guarded by the node's sticky flag so it is reached at most once per node per key. The
5
+ * `role` comparison comes first because it is the one alias with no prefix.
6
+ */
7
+ export declare function isAriaAliasKey(key: string): boolean;
8
+ /**
9
+ * Fold the web-alias `aria-*` / `role` props into RN's canonical `accessibility*` props.
10
+ *
11
+ * Returns the input BY IDENTITY when no alias is present — the fast path that keeps this off the
12
+ * hot path for the ~99% of nodes carrying none, and the property idempotence rests on: pass 1
13
+ * blanks every alias, so a second pass finds nothing and returns by identity again.
14
+ *
15
+ * The alias keys are blanked to `undefined` rather than deleted. That is not laziness: `setProp`
16
+ * treats an `undefined` write as a delete and `fabricProps` skips undefined, so a blanked alias
17
+ * cannot reach Fabric, while a `delete` would deoptimise the object's shape on every folded node.
18
+ */
19
+ export declare function foldAriaProps(props: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,209 @@
1
+ // RN's `aria-*` / `role` -> `accessibility*` fold, at the layer every path goes through.
2
+ //
3
+ // WHY IT IS HERE AND NOT IN A WRAPPER. It used to run inside each primitive's COMPONENT, which is
4
+ // exactly the layer host-primitive lowering removes. A per-attribute element path cannot do it —
5
+ // `aria-checked` has to be folded against a sibling `accessibilityState` — so the four lowering
6
+ // transforms REFUSED any element carrying `role` or an `aria-*` attribute
7
+ // (`REFUSAL_CATEGORIES.bagFold`). Accessibility props are ordinary in real code, so that refusal
8
+ // cost lowering coverage on every primitive, including the three already lowered. Moving the fold
9
+ // down deletes the refusal instead of teaching four transforms a bag operation they cannot express.
10
+ //
11
+ // IT IS A MOVE, NOT A REWRITE, AND THAT IS DELIBERATE. The function carries TWO CONTRADICTORY
12
+ // PRECEDENCE RULES: for the scalars an explicit `accessibility*` WINS and the alias only fills a
13
+ // hole (`if (next.X === undefined)`), while INSIDE the `accessibilityState` / `accessibilityValue`
14
+ // composites the ALIAS wins per field (`ariaBusy ?? existing?.busy`). Both mirror RN's View.js.
15
+ // Anyone "cleaning this up" collapses them into one rule, and every component test stays green
16
+ // while real accessibility silently changes. `core/components/src/accessibility-props.test.ts`
17
+ // pins both directions; read it before touching the branches below.
18
+ //
19
+ // Record-level rather than typed, because the engine's caller has a raw `node.props` bag and an
20
+ // interface is not assignable to `Record<string, unknown>` (no index signature). The typed
21
+ // `resolveAccessibilityProps<T>` in `core/components` stays where adapters already import it and
22
+ // delegates here, keeping its own typed gate so the fast path allocates nothing.
23
+ import { dlog } from './debug.js';
24
+ // Copied line for line from the wrapper this replaces. The first copy silently dropped five
25
+ // entries (`button`, `grid`, `link`, `list`, `listitem`) — a role that falls through simply passes
26
+ // unmapped, so `role="listitem"` would have reached Fabric as `listitem` instead of `list` with
27
+ // nothing red anywhere. Diff this against RN's View.js rather than reading it for plausibility.
28
+ const ROLE_TO_ACCESSIBILITY_ROLE = {
29
+ alert: 'alert',
30
+ button: 'button',
31
+ checkbox: 'checkbox',
32
+ combobox: 'combobox',
33
+ grid: 'grid',
34
+ heading: 'header',
35
+ img: 'image',
36
+ link: 'link',
37
+ list: 'list',
38
+ listitem: 'list',
39
+ menu: 'menu',
40
+ menubar: 'menubar',
41
+ menuitem: 'menuitem',
42
+ none: 'none',
43
+ presentation: 'none',
44
+ progressbar: 'progressbar',
45
+ radio: 'radio',
46
+ radiogroup: 'radiogroup',
47
+ scrollbar: 'scrollbar',
48
+ searchbox: 'search',
49
+ slider: 'adjustable',
50
+ spinbutton: 'spinbutton',
51
+ summary: 'summary',
52
+ switch: 'switch',
53
+ tab: 'tab',
54
+ tablist: 'tablist',
55
+ timer: 'timer',
56
+ toolbar: 'toolbar',
57
+ };
58
+ const ARIA_KEYS = [
59
+ 'role',
60
+ 'aria-label',
61
+ 'aria-labelledby',
62
+ 'aria-live',
63
+ 'aria-hidden',
64
+ 'aria-busy',
65
+ 'aria-checked',
66
+ 'aria-disabled',
67
+ 'aria-expanded',
68
+ 'aria-selected',
69
+ 'aria-modal',
70
+ 'aria-valuemax',
71
+ 'aria-valuemin',
72
+ 'aria-valuenow',
73
+ 'aria-valuetext',
74
+ ];
75
+ // An indexed loop rather than `.some(key => …)`: the callback captures `props`, so a closure is
76
+ // allocated per call, and this is the gate on a path that runs once per node.
77
+ function hasAnyAriaKey(props) {
78
+ for (let index = 0; index < ARIA_KEYS.length; index += 1) {
79
+ if (props[ARIA_KEYS[index]] !== undefined)
80
+ return true;
81
+ }
82
+ return false;
83
+ }
84
+ /**
85
+ * Whether one key is an alias this fold consumes. `startsWith` rather than a Set lookup: this runs
86
+ * on `setProp`, the hottest write path in the engine (32 001 writes on one benchmark create), and
87
+ * it is guarded by the node's sticky flag so it is reached at most once per node per key. The
88
+ * `role` comparison comes first because it is the one alias with no prefix.
89
+ */
90
+ export function isAriaAliasKey(key) {
91
+ return key === 'role' || key.startsWith('aria-');
92
+ }
93
+ function isRecord(value) {
94
+ return typeof value === 'object' && value !== null;
95
+ }
96
+ function fieldOf(source, field) {
97
+ return isRecord(source) ? source[field] : undefined;
98
+ }
99
+ /**
100
+ * Fold the web-alias `aria-*` / `role` props into RN's canonical `accessibility*` props.
101
+ *
102
+ * Returns the input BY IDENTITY when no alias is present — the fast path that keeps this off the
103
+ * hot path for the ~99% of nodes carrying none, and the property idempotence rests on: pass 1
104
+ * blanks every alias, so a second pass finds nothing and returns by identity again.
105
+ *
106
+ * The alias keys are blanked to `undefined` rather than deleted. That is not laziness: `setProp`
107
+ * treats an `undefined` write as a delete and `fabricProps` skips undefined, so a blanked alias
108
+ * cannot reach Fabric, while a `delete` would deoptimise the object's shape on every folded node.
109
+ */
110
+ export function foldAriaProps(props) {
111
+ if (!hasAnyAriaKey(props))
112
+ return props;
113
+ const bag = { ...props };
114
+ dlog('foldAriaProps: folding aria/role aliases into accessibility* props');
115
+ const role = bag.role;
116
+ const ariaLabel = bag['aria-label'];
117
+ const ariaLabelledBy = bag['aria-labelledby'];
118
+ const ariaLive = bag['aria-live'];
119
+ const ariaHidden = bag['aria-hidden'];
120
+ const ariaBusy = bag['aria-busy'];
121
+ const ariaChecked = bag['aria-checked'];
122
+ const ariaDisabled = bag['aria-disabled'];
123
+ const ariaExpanded = bag['aria-expanded'];
124
+ const ariaSelected = bag['aria-selected'];
125
+ const ariaModal = bag['aria-modal'];
126
+ const ariaValueMax = bag['aria-valuemax'];
127
+ const ariaValueMin = bag['aria-valuemin'];
128
+ const ariaValueNow = bag['aria-valuenow'];
129
+ const ariaValueText = bag['aria-valuetext'];
130
+ for (let index = 0; index < ARIA_KEYS.length; index += 1) {
131
+ bag[ARIA_KEYS[index]] = undefined;
132
+ }
133
+ // RULE ONE, for every scalar: the explicit prop WINS, the alias only fills a hole.
134
+ if (typeof ariaLabelledBy === 'string' &&
135
+ bag.accessibilityLabelledBy === undefined) {
136
+ bag.accessibilityLabelledBy = ariaLabelledBy.split(/\s*,\s*/g);
137
+ }
138
+ if (ariaLabel !== undefined && bag.accessibilityLabel === undefined) {
139
+ bag.accessibilityLabel = ariaLabel;
140
+ }
141
+ if (ariaLive !== undefined && bag.accessibilityLiveRegion === undefined) {
142
+ bag.accessibilityLiveRegion = ariaLive === 'off' ? 'none' : ariaLive;
143
+ }
144
+ // One input, TWO outputs, and the second is conditional on the VALUE rather than on presence.
145
+ if (ariaHidden !== undefined) {
146
+ if (bag.accessibilityElementsHidden === undefined) {
147
+ bag.accessibilityElementsHidden = ariaHidden;
148
+ }
149
+ if (ariaHidden === true && bag.importantForAccessibility === undefined) {
150
+ bag.importantForAccessibility = 'no-hide-descendants';
151
+ }
152
+ }
153
+ if (ariaModal !== undefined && bag.accessibilityViewIsModal === undefined) {
154
+ bag.accessibilityViewIsModal = ariaModal;
155
+ }
156
+ if (typeof role === 'string' && bag.accessibilityRole === undefined) {
157
+ bag.accessibilityRole = ROLE_TO_ACCESSIBILITY_ROLE[role] ?? role;
158
+ }
159
+ // RULE TWO, INSIDE the composites: the polarity INVERTS and the ALIAS wins per field. Read from
160
+ // the ORIGINAL props, not from `bag` — the loop above has already blanked the aliases there.
161
+ //
162
+ // UPSTREAM-BUG(react-native): View.js:96 is `checked: ariaChecked ?? accessibilityState?.checked`
163
+ // — NO type coercion. A template writes `aria-checked="true"` as a STRING in every framework we
164
+ // support, so `accessibilityState.checked` reaches native as `'true'` where the native side
165
+ // declares `boolean | 'mixed'`. Ported verbatim for parity; do NOT add a cast without recording
166
+ // a deliberate divergence. Found by the Svelte session 2026-08-31 and pinned as an ASSERTION in
167
+ // `adapters/svelte/src/aria-fold-parity.test.ts`, so a future decision to coerce shows up as a
168
+ // failing test at the point the decision was made rather than as a silent behaviour change.
169
+ //
170
+ // A SECOND, SMALLER DIVERGENCE, and this one is ours rather than upstream's — it predates the
171
+ // move down and is kept only because changing it here would be an undeclared behaviour change:
172
+ // upstream gates the composite on `!= null` (View.js:87-92) and this gates on `!== undefined`.
173
+ // So an explicit `aria-busy={null}` builds an all-undefined `accessibilityState` here and builds
174
+ // nothing upstream. The VALUES agree either way — `??` treats null and undefined alike — so only
175
+ // the composite's existence differs.
176
+ //
177
+ // The composite is REPLACED by a fresh literal listing exactly the known fields, so an unknown
178
+ // field riding on the incoming object is dropped. Faithful to RN and pinned by a test; it is the
179
+ // shape of bug that only shows for whoever passes a field RN adds later.
180
+ const existingState = fieldOf(props, 'accessibilityState');
181
+ if (existingState !== undefined ||
182
+ ariaBusy !== undefined ||
183
+ ariaChecked !== undefined ||
184
+ ariaDisabled !== undefined ||
185
+ ariaExpanded !== undefined ||
186
+ ariaSelected !== undefined) {
187
+ bag.accessibilityState = {
188
+ busy: ariaBusy ?? fieldOf(existingState, 'busy'),
189
+ checked: ariaChecked ?? fieldOf(existingState, 'checked'),
190
+ disabled: ariaDisabled ?? fieldOf(existingState, 'disabled'),
191
+ expanded: ariaExpanded ?? fieldOf(existingState, 'expanded'),
192
+ selected: ariaSelected ?? fieldOf(existingState, 'selected'),
193
+ };
194
+ }
195
+ const existingValue = fieldOf(props, 'accessibilityValue');
196
+ if (existingValue !== undefined ||
197
+ ariaValueMax !== undefined ||
198
+ ariaValueMin !== undefined ||
199
+ ariaValueNow !== undefined ||
200
+ ariaValueText !== undefined) {
201
+ bag.accessibilityValue = {
202
+ max: ariaValueMax ?? fieldOf(existingValue, 'max'),
203
+ min: ariaValueMin ?? fieldOf(existingValue, 'min'),
204
+ now: ariaValueNow ?? fieldOf(existingValue, 'now'),
205
+ text: ariaValueText ?? fieldOf(existingValue, 'text'),
206
+ };
207
+ }
208
+ return bag;
209
+ }
@@ -14,6 +14,7 @@ export type ITaskCanceller = () => void;
14
14
  export type ITaskCancelProvider = () => ITaskCanceller;
15
15
  export interface IHostRegistrar {
16
16
  registerRunnable(appKey: string, run: IRunnable): string;
17
+ registerCancellableHeadlessTask?(taskKey: string, taskProvider: ITaskProvider, taskCancelProvider: ITaskCancelProvider): void;
17
18
  unmountAtRootTag?(rootTag: IRootTag): void;
18
19
  }
19
20
  export interface IAppRegistry<TComponentProvider, TWrapperComponentProvider> {
@@ -6,11 +6,9 @@
6
6
  // the one thing each adapter supplies; everything else (registry bookkeeping, sections, the
7
7
  // host-registrar bridge, headless tasks) is byte-identical across adapters and lives here once.
8
8
  //
9
- // The catch: the native Fabric host invokes RN's AppRegistry (a registered callable module) by
10
- // app key, and it can't see ours. So registerComponent must also hand its runnable to RN's
11
- // registrar. Reached the same way shared reaches processColor: a dependency-injected seam
12
- // (setHostRegistrar), so the core stays react-native-free and the app glue wires the host once
13
- // at startup.
9
+ // The catch: native invokes RN's registered callable AppRegistry for both surface runnables and
10
+ // headless tasks; it cannot see this local registry. `setHostRegistrar` mirrors those native-facing
11
+ // registrations there while the core remains react-native-free.
14
12
  import { getNativeModule } from '../native-modules/index.js';
15
13
  import { dlog } from '../debug.js';
16
14
  const HEADLESS_TASK_MODULE = 'HeadlessJsTaskSupport';
@@ -121,6 +119,7 @@ export function createAppRegistry(runnableFor) {
121
119
  }
122
120
  taskProviders.set(taskKey, taskProvider);
123
121
  taskCancelProviders.set(taskKey, taskCancelProvider);
122
+ hostRegistrar?.registerCancellableHeadlessTask?.(taskKey, taskProvider, taskCancelProvider);
124
123
  },
125
124
  startHeadlessTask(taskId, taskKey, data) {
126
125
  runHeadlessTask(taskId, taskKey, data);
@@ -138,7 +137,18 @@ export function createAppRegistry(runnableFor) {
138
137
  return {
139
138
  AppRegistry,
140
139
  setHostRegistrar(registrar) {
140
+ if (hostRegistrar === registrar)
141
+ return;
141
142
  hostRegistrar = registrar;
143
+ // Headless tasks are commonly registered before adapter bootstrap attaches RN's callable
144
+ // AppRegistry. Replay only this missing native-facing registry; app runnables retain their
145
+ // existing register-after-bootstrap contract.
146
+ for (const [taskKey, taskProvider] of taskProviders) {
147
+ const taskCancelProvider = taskCancelProviders.get(taskKey);
148
+ if (taskCancelProvider !== undefined) {
149
+ registrar.registerCancellableHeadlessTask?.(taskKey, taskProvider, taskCancelProvider);
150
+ }
151
+ }
142
152
  },
143
153
  };
144
154
  }
package/build/commit.d.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  import { type IFabricNode, type IRootTag, type IMeasureOnSuccess, type IMeasureInWindowOnSuccess, type IMeasureLayoutOnSuccess } from './fabric';
2
2
  import { type ISymbioteNode } from './node';
3
3
  export { processColor, setColorProcessor } from './platform-color';
4
+ declare global {
5
+ var __SYMBIOTE_BATCH_CREATE__: boolean | undefined;
6
+ }
4
7
  export declare function disposeRoot(rootTag: IRootTag): void;
5
8
  export declare function commitChildren(rootTag: IRootTag, children: readonly ISymbioteNode[]): void;
6
9
  export interface ICommitProfile {
7
10
  commits: number;
8
11
  walkMs: number;
9
12
  nodesVisited: number;
13
+ /** Update-path nodes that rebuilt their Fabric payload and deep-compared it. */
14
+ propsBuilt: number;
15
+ /** Update-path nodes that re-cloned for a child but reused the committed payload by reference. */
16
+ propsReused: number;
10
17
  propWrites: number;
11
18
  propNoops: number;
12
19
  childScans: number;
@@ -16,7 +23,22 @@ export interface ICommitProfile {
16
23
  childFlattenWidest: number;
17
24
  }
18
25
  export declare function readCommitProfile(): ICommitProfile;
26
+ export declare function flushNativeProps(): void;
19
27
  export declare function setNativeProps(node: ISymbioteNode, partial: Record<string, unknown>): void;
28
+ /**
29
+ * Publish a node whose props were changed OUTSIDE any renderer mutation.
30
+ *
31
+ * Dirtying is not publishing. Every other write reaches Fabric because the framework's own commit
32
+ * follows it; a change driven by a NATIVE EVENT has no such follow-up — `native-events.ts`
33
+ * requests no commit and no adapter does either, so a node marked dirty from an event handler
34
+ * simply stays dirty. The host-behavior press path (`setNodePressed` for `:active`) is the first
35
+ * caller that is not `setNativeProps`, and `setNodeHidden`'s React twin never needed it because
36
+ * the reconciler is already in its commit phase when it calls.
37
+ *
38
+ * Queued rather than committed on the spot, sharing `setNativeProps`' batch: several writes in one
39
+ * task publish together at the microtask boundary, one commit per surface.
40
+ */
41
+ export declare function requestCommitFor(node: ISymbioteNode): void;
20
42
  export declare function getNativeTag(node: ISymbioteNode): number | undefined;
21
43
  export declare function whenCommitted(node: ISymbioteNode, action: () => void): () => void;
22
44
  export declare function getNativeNode(node: ISymbioteNode): IFabricNode | undefined;