@ccheever/exact-renderer 0.1.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.
Files changed (80) hide show
  1. package/package.json +118 -0
  2. package/src/__tests__/adapter-window-state.test.tsx +190 -0
  3. package/src/__tests__/attrs.test.ts +157 -0
  4. package/src/__tests__/classname.test.ts +332 -0
  5. package/src/__tests__/color.test.ts +169 -0
  6. package/src/__tests__/dom-mirror.test.ts +682 -0
  7. package/src/__tests__/dom-shim.test.ts +274 -0
  8. package/src/__tests__/fixtures/SvelteCounter.svelte +7 -0
  9. package/src/__tests__/fixtures/SvelteInput.svelte +8 -0
  10. package/src/__tests__/host-config.test.ts +51 -0
  11. package/src/__tests__/host-ops.test.ts +2234 -0
  12. package/src/__tests__/image-source.test.ts +135 -0
  13. package/src/__tests__/liquid-glass.test.ts +72 -0
  14. package/src/__tests__/multi-root.test.ts +118 -0
  15. package/src/__tests__/native-view-events.test.ts +102 -0
  16. package/src/__tests__/nodes.test.ts +399 -0
  17. package/src/__tests__/normalize.test.ts +576 -0
  18. package/src/__tests__/paragraph-lowering.test.tsx +144 -0
  19. package/src/__tests__/props.test.ts +518 -0
  20. package/src/__tests__/protocol-encoder.test.ts +732 -0
  21. package/src/__tests__/protocol-fixture-bytes.test.ts +41 -0
  22. package/src/__tests__/reconciler.test.tsx +241 -0
  23. package/src/__tests__/svelte-adapter.test.ts +166 -0
  24. package/src/__tests__/svg-source.test.ts +71 -0
  25. package/src/__tests__/tags.test.ts +354 -0
  26. package/src/__tests__/toggle.test.ts +441 -0
  27. package/src/__tests__/transitions.test.ts +106 -0
  28. package/src/__tests__/web-primitives.test.tsx +454 -0
  29. package/src/__tests__/window-hooks.test.tsx +447 -0
  30. package/src/adapter-contract.ts +68 -0
  31. package/src/attrs.ts +596 -0
  32. package/src/classname-contract.ts +87 -0
  33. package/src/classname-resolve.ts +553 -0
  34. package/src/classname-runtime.ts +29 -0
  35. package/src/components.ts +214 -0
  36. package/src/css-variable-context.ts +83 -0
  37. package/src/dom-hydration.ts +160 -0
  38. package/src/dom-mirror.ts +1459 -0
  39. package/src/dom-shim.ts +1736 -0
  40. package/src/group-context.ts +69 -0
  41. package/src/host-config.ts +431 -0
  42. package/src/host-ops.ts +3167 -0
  43. package/src/image-source.native.ts +703 -0
  44. package/src/image-source.ts +554 -0
  45. package/src/index.ts +278 -0
  46. package/src/inspector-runtime.ts +244 -0
  47. package/src/inspector.ts +3570 -0
  48. package/src/jsx-augmentations.ts +54 -0
  49. package/src/keyboard-avoidance.ts +217 -0
  50. package/src/native-primitives.ts +43 -0
  51. package/src/native-view-events.ts +322 -0
  52. package/src/native-view.ts +60 -0
  53. package/src/nodes/index.ts +41 -0
  54. package/src/nodes/node.ts +531 -0
  55. package/src/peer-context.ts +100 -0
  56. package/src/primitives.native.ts +8 -0
  57. package/src/primitives.ts +8 -0
  58. package/src/props/index.ts +14 -0
  59. package/src/props/normalize.ts +816 -0
  60. package/src/protocol/encoder.ts +940 -0
  61. package/src/protocol/index.ts +33 -0
  62. package/src/reconciler.ts +581 -0
  63. package/src/runtime.ts +11 -0
  64. package/src/safe-area.ts +543 -0
  65. package/src/solid.ts +490 -0
  66. package/src/style/color.js +1 -0
  67. package/src/style/color.ts +15 -0
  68. package/src/style/index.js +1 -0
  69. package/src/style/index.ts +22 -0
  70. package/src/style/normalize.js +1 -0
  71. package/src/style/normalize.ts +1426 -0
  72. package/src/svelte.ts +349 -0
  73. package/src/svg-source.ts +222 -0
  74. package/src/tags/index.ts +21 -0
  75. package/src/tags/tag-map.ts +289 -0
  76. package/src/text/paragraph-lowering.ts +310 -0
  77. package/src/types.ts +1175 -0
  78. package/src/vue.ts +535 -0
  79. package/src/web-host.ts +19 -0
  80. package/src/web-primitives.ts +1654 -0
@@ -0,0 +1,3167 @@
1
+ /**
2
+ * Host Operations - Shared Infrastructure
3
+ *
4
+ * This module provides framework-agnostic operations for the Exact renderer.
5
+ * It is used by React, Solid, and Vue adapters to:
6
+ * - Encode protocol messages to native
7
+ * - Manage event handler registry
8
+ * - Create and manipulate nodes
9
+ *
10
+ * Design Principles:
11
+ * - Framework-agnostic: no React/Solid/Vue dependencies
12
+ * - Single source of truth for protocol encoding
13
+ * - Shared event handler registry across all frameworks
14
+ */
15
+
16
+ import type { ElementNode, TextNode, RootNode } from './nodes/node.js';
17
+ import type { ParagraphSpec } from '@exact/text';
18
+ import {
19
+ EventType,
20
+ NodeType,
21
+ } from '@exact/core/protocol/opcodes';
22
+ import {
23
+ clearFocusedTarget,
24
+ setFocusedTarget,
25
+ } from '@exact/core/agent/interaction-state';
26
+ import { recordInboundProtocolEvent, resetProtocolTapForTests } from '@exact/core/agent';
27
+ import {
28
+ detectTextDirection,
29
+ } from '@exact/core/i18n/helpers';
30
+ import {
31
+ getExactAccessibilitySnapshot,
32
+ subscribeExactAccessibilityChanges,
33
+ } from '@exact/core/runtime/accessibility-state';
34
+ import {
35
+ getExactLocaleSnapshot,
36
+ subscribeExactLocaleChanges,
37
+ } from '@exact/core/runtime/locale-state';
38
+ import type {
39
+ CanonicalTagType,
40
+ FullStyle,
41
+ CanonicalStyle,
42
+ CanonicalProps,
43
+ CanonicalTransitionMap,
44
+ FlexDirection,
45
+ Direction,
46
+ ResolvedDirection,
47
+ SafeAreaPropagationState,
48
+ WritingMode,
49
+ } from './types.js';
50
+
51
+ import {
52
+ DirtyFlags,
53
+ NodeKind,
54
+ clearDirtyFlags,
55
+ createElementNode,
56
+ createTextNode,
57
+ createRootNode,
58
+ hasDirtyFlag,
59
+ appendChild as nodeAppendChild,
60
+ insertBefore as nodeInsertBefore,
61
+ removeChild as nodeRemoveChild,
62
+ clearChildren as nodeClearChildren,
63
+ } from './nodes/node.js';
64
+
65
+ import { getTagConfig, defaultTagConfig, type TagConfig } from './tags/index.js';
66
+ import {
67
+ registerNativeViewEventHandler,
68
+ unregisterNativeViewEventHandler,
69
+ } from './native-view-events.js';
70
+ import {
71
+ resolveClassNameStyle,
72
+ type ClassNameResolutionContext,
73
+ } from './classname-runtime.js';
74
+ import { normalizeStyle, normalizeTransition, stylesEqual, transitionsEqual } from './style/index.js';
75
+ import { normalizeProps, propsEqual } from './props/index.js';
76
+ import { createRootSafeAreaPropagation, resolveSafeAreaForElement } from './safe-area.js';
77
+ import {
78
+ clearFocusedInput,
79
+ noteFocusedInput,
80
+ resetKeyboardAvoidanceState,
81
+ syncKeyboardAvoidanceAfterCommit,
82
+ } from './keyboard-avoidance.js';
83
+ import {
84
+ type ProtocolEncoder,
85
+ createProtocolEncoder,
86
+ setStyle,
87
+ setTransform,
88
+ setOpacity,
89
+ setBackgroundColor,
90
+ setTransition,
91
+ setLang,
92
+ setTextContent,
93
+ dispatchProtocol,
94
+ getScreenDimensions,
95
+ encodeCreateElement,
96
+ encodeCreateText,
97
+ encodeProps,
98
+ encodeEvents,
99
+ encodeChildren,
100
+ encodeDestroy,
101
+ } from './protocol/index.js';
102
+ import { notifyCommit, registerRoot, unregisterRoot } from './inspector-runtime.js';
103
+ import { lowerTextPropsToParagraphSpec } from './text/paragraph-lowering.js';
104
+
105
+ // =============================================================================
106
+ // Development Mode Flag
107
+ // =============================================================================
108
+
109
+ /**
110
+ * Development mode flag - gate verbose logging.
111
+ * In production builds, bundlers should replace this with false.
112
+ */
113
+ const __DEV__ = process.env.NODE_ENV !== 'production';
114
+ const warnedAutoDirectionTags = new Set<string>();
115
+ const liveRoots = new Map<number, RootNode>();
116
+ const rootOwners = new Map<number, string>();
117
+ const committedRootViewportSizes = new Map<number, { width: number; height: number }>();
118
+ const pendingCommitStateByRoot = new Map<number, PendingCommitState>();
119
+ let accessibilityChangeUnsubscribe: (() => void) | null = null;
120
+ let localeChangeUnsubscribe: (() => void) | null = null;
121
+
122
+ interface InheritedElementState {
123
+ rootId: number;
124
+ direction: ResolvedDirection;
125
+ writingMode: WritingMode;
126
+ lang: string;
127
+ safeArea: SafeAreaPropagationState;
128
+ }
129
+
130
+ interface ResolvedElementState {
131
+ props: CanonicalProps;
132
+ paragraphSpec: ParagraphSpec | null;
133
+ paragraphKey: string | null;
134
+ style: CanonicalStyle;
135
+ transitions: CanonicalTransitionMap;
136
+ resolvedDirection: ResolvedDirection;
137
+ resolvedWritingMode: WritingMode;
138
+ resolvedLang: string;
139
+ safeAreaState: ElementNode['safeAreaState'];
140
+ propagatedSafeArea: ElementNode['propagatedSafeArea'];
141
+ }
142
+
143
+ interface PendingCommitState {
144
+ layoutDirty: boolean;
145
+ warningAnalysisDirty: boolean;
146
+ dirtyChildren: Set<RootNode | ElementNode>;
147
+ changedViews: Set<number>;
148
+ }
149
+
150
+ const SINGLE_LINE_INPUT_MIN_HEIGHT = 36;
151
+ const TOGGLE_NATIVE_VIEW_MIN_WIDTH = 46;
152
+ const TOGGLE_NATIVE_VIEW_MIN_HEIGHT = 28;
153
+
154
+ function applyPropDrivenStyleOverrides(
155
+ tagType: CanonicalTagType,
156
+ style: CanonicalStyle,
157
+ props: CanonicalProps,
158
+ originalProps: Props,
159
+ ): CanonicalStyle {
160
+ let next = style;
161
+
162
+ if ((tagType === 'image' || tagType === 'svg') && props.resizeMode !== undefined) {
163
+ next = {
164
+ ...next,
165
+ resizeMode: props.resizeMode,
166
+ };
167
+ }
168
+
169
+ if (tagType === 'input' && props.multiline === true) {
170
+ const lifted = normalizeStyle(
171
+ { numberOfLines: originalProps.numberOfLines ?? 2 } as FullStyle,
172
+ 'row',
173
+ );
174
+ if (lifted.numberOfLines !== undefined) {
175
+ next = {
176
+ ...next,
177
+ numberOfLines: lifted.numberOfLines,
178
+ };
179
+ }
180
+ }
181
+
182
+ if (
183
+ tagType === 'input' &&
184
+ props.multiline !== true &&
185
+ next.height === undefined &&
186
+ next.minHeight === undefined
187
+ ) {
188
+ next = {
189
+ ...next,
190
+ minHeight: { type: 'points', value: SINGLE_LINE_INPUT_MIN_HEIGHT },
191
+ };
192
+ }
193
+
194
+ if (tagType === 'nativeView' && props.nativeViewModuleName === 'exact.toggle') {
195
+ if (next.width === undefined && next.minWidth === undefined) {
196
+ next = {
197
+ ...next,
198
+ minWidth: { type: 'points', value: TOGGLE_NATIVE_VIEW_MIN_WIDTH },
199
+ };
200
+ }
201
+ if (next.height === undefined && next.minHeight === undefined) {
202
+ next = {
203
+ ...next,
204
+ minHeight: { type: 'points', value: TOGGLE_NATIVE_VIEW_MIN_HEIGHT },
205
+ };
206
+ }
207
+ }
208
+
209
+ return next;
210
+ }
211
+
212
+ function applyTextLayoutPropStyleOverrides(
213
+ tagType: CanonicalTagType,
214
+ style: CanonicalStyle,
215
+ props: Props,
216
+ ): CanonicalStyle {
217
+ if (tagType !== 'text') {
218
+ return style;
219
+ }
220
+
221
+ let next = style;
222
+ if (props.numberOfLines !== undefined) {
223
+ const lifted = normalizeStyle(
224
+ { numberOfLines: props.numberOfLines } as FullStyle,
225
+ 'row',
226
+ );
227
+ if (lifted.numberOfLines !== undefined) {
228
+ next = { ...next, numberOfLines: lifted.numberOfLines };
229
+ }
230
+ }
231
+ if (props.ellipsizeMode !== undefined) {
232
+ const lifted = normalizeStyle(
233
+ { ellipsizeMode: props.ellipsizeMode } as FullStyle,
234
+ 'row',
235
+ );
236
+ if (lifted.ellipsizeMode !== undefined) {
237
+ next = { ...next, ellipsizeMode: lifted.ellipsizeMode };
238
+ }
239
+ }
240
+ return next;
241
+ }
242
+
243
+ function applyAccessibilityDrivenStyleOverrides(
244
+ style: CanonicalStyle,
245
+ props: CanonicalProps,
246
+ ): CanonicalStyle {
247
+ if (style.fontSize === undefined || props.allowFontScaling === false) {
248
+ return style;
249
+ }
250
+
251
+ const accessibility = getExactAccessibilitySnapshot();
252
+ const rawScale = accessibility.fontScale > 0 ? accessibility.fontScale : 1;
253
+ const maxScale =
254
+ typeof props.maxFontSizeMultiplier === 'number' &&
255
+ Number.isFinite(props.maxFontSizeMultiplier) &&
256
+ props.maxFontSizeMultiplier > 0
257
+ ? props.maxFontSizeMultiplier
258
+ : Number.POSITIVE_INFINITY;
259
+ const fontScale = Math.min(rawScale, maxScale);
260
+ const minimumFontSize =
261
+ typeof props.minimumFontSize === 'number' &&
262
+ Number.isFinite(props.minimumFontSize) &&
263
+ props.minimumFontSize > 0
264
+ ? props.minimumFontSize
265
+ : undefined;
266
+ const scaledFontSize =
267
+ minimumFontSize == null
268
+ ? style.fontSize * fontScale
269
+ : Math.max(style.fontSize * fontScale, minimumFontSize);
270
+
271
+ return {
272
+ ...style,
273
+ // Accessibility preferences affect layout, so text scaling is resolved
274
+ // before protocol encoding and Taffy measurement rather than as a paint-only
275
+ // native tweak.
276
+ fontSize: scaledFontSize,
277
+ lineHeight:
278
+ typeof style.lineHeight === 'number' && Number.isFinite(style.lineHeight) && style.lineHeight > 0
279
+ ? style.lineHeight * fontScale
280
+ : style.lineHeight,
281
+ };
282
+ }
283
+
284
+ function isPercentMinHeight(value: CanonicalStyle['minHeight']): boolean {
285
+ return (
286
+ typeof value === 'object' &&
287
+ value !== null &&
288
+ 'type' in value &&
289
+ value.type === 'percent' &&
290
+ value.value === 100
291
+ );
292
+ }
293
+
294
+ function applyRootScrollViewportDefaults(
295
+ instance: ElementNode,
296
+ style: CanonicalStyle,
297
+ ): CanonicalStyle {
298
+ if (instance.tagType !== 'scroll' || instance.parent?.kind !== NodeKind.Root) {
299
+ return style;
300
+ }
301
+
302
+ if (style.height !== undefined) {
303
+ return style;
304
+ }
305
+
306
+ const next: CanonicalStyle = { ...style };
307
+
308
+ // Percent min-height makes a root scroll grow with its content instead of
309
+ // staying viewport-bound; flex children need minHeight 0 to shrink and scroll.
310
+ if (isPercentMinHeight(next.minHeight)) {
311
+ delete next.minHeight;
312
+ }
313
+
314
+ if (next.flexGrow === undefined) {
315
+ next.flexGrow = 1;
316
+ next.flexShrink = 1;
317
+ next.flexBasis = { type: 'points', value: 0 };
318
+ }
319
+
320
+ return next;
321
+ }
322
+
323
+ function buildDefaultCanonicalStyle(
324
+ defaultFlexDirection: FlexDirection,
325
+ isRNStyle = false,
326
+ ): CanonicalStyle {
327
+ const style: CanonicalStyle = defaultFlexDirection === 'row'
328
+ ? {}
329
+ : { flexDirection: defaultFlexDirection };
330
+
331
+ if (isRNStyle) {
332
+ style.flexShrink = 0;
333
+ }
334
+
335
+ return style;
336
+ }
337
+
338
+ function safeAreaInsetsEqual(
339
+ left: SafeAreaPropagationState['regions']['container'],
340
+ right: SafeAreaPropagationState['regions']['container'],
341
+ ): boolean {
342
+ return (
343
+ left.top === right.top &&
344
+ left.right === right.right &&
345
+ left.bottom === right.bottom &&
346
+ left.left === right.left
347
+ );
348
+ }
349
+
350
+ function keyboardStateEqual(
351
+ left: SafeAreaPropagationState['keyboard'],
352
+ right: SafeAreaPropagationState['keyboard'],
353
+ ): boolean {
354
+ return (
355
+ left.visible === right.visible &&
356
+ left.occlusion.x === right.occlusion.x &&
357
+ left.occlusion.y === right.occlusion.y &&
358
+ left.occlusion.width === right.occlusion.width &&
359
+ left.occlusion.height === right.occlusion.height &&
360
+ left.progress === right.progress &&
361
+ left.animating === right.animating &&
362
+ left.mode === right.mode &&
363
+ left.animationCurve === right.animationCurve &&
364
+ left.animationDurationMs === right.animationDurationMs &&
365
+ left.interactive === right.interactive &&
366
+ left.accessoryHeight === right.accessoryHeight &&
367
+ left.source === right.source
368
+ );
369
+ }
370
+
371
+ function safeAreaPropagationEqual(
372
+ left: SafeAreaPropagationState | null,
373
+ right: SafeAreaPropagationState | null,
374
+ ): boolean {
375
+ if (left === right) {
376
+ return true;
377
+ }
378
+ if (!left || !right) {
379
+ return false;
380
+ }
381
+ return (
382
+ left.rootId === right.rootId &&
383
+ safeAreaInsetsEqual(left.regions.container, right.regions.container) &&
384
+ safeAreaInsetsEqual(left.regions.displayCutout, right.regions.displayCutout) &&
385
+ safeAreaInsetsEqual(left.regions.gestures, right.regions.gestures) &&
386
+ keyboardStateEqual(left.keyboard, right.keyboard)
387
+ );
388
+ }
389
+
390
+ const LAYOUT_AFFECTING_STYLE_KEYS =
391
+ '|width|height|minWidth|minHeight|maxWidth|maxHeight|paddingTop|paddingRight|paddingBottom|paddingLeft|marginTop|marginRight|marginBottom|marginLeft|flexDirection|flexWrap|justifyContent|alignItems|alignSelf|flexGrow|flexShrink|flexBasis|rowGap|columnGap|positionType|top|right|bottom|left|display|overflow|aspectRatio|direction|writingMode|fontFamily|fontSize|fontWeight|fontStyle|fontVariantNumeric|lineHeight|letterSpacing|numberOfLines|ellipsizeMode|';
392
+
393
+ const TRANSPARENT_COLOR: NonNullable<CanonicalStyle['backgroundColor']> = Object.freeze({
394
+ r: 0,
395
+ g: 0,
396
+ b: 0,
397
+ a: 0,
398
+ });
399
+
400
+ function canonicalStyleValuesEqual(left: unknown, right: unknown): boolean {
401
+ if (left === right) {
402
+ return true;
403
+ }
404
+
405
+ if (
406
+ typeof left === 'object' &&
407
+ left !== null &&
408
+ typeof right === 'object' &&
409
+ right !== null
410
+ ) {
411
+ const leftRecord = left as Record<string, unknown>;
412
+ const rightRecord = right as Record<string, unknown>;
413
+
414
+ if ('type' in leftRecord || 'type' in rightRecord) {
415
+ return leftRecord.type === rightRecord.type && leftRecord.value === rightRecord.value;
416
+ }
417
+
418
+ if ('r' in leftRecord || 'r' in rightRecord) {
419
+ return (
420
+ leftRecord.r === rightRecord.r &&
421
+ leftRecord.g === rightRecord.g &&
422
+ leftRecord.b === rightRecord.b &&
423
+ leftRecord.a === rightRecord.a
424
+ );
425
+ }
426
+ }
427
+
428
+ return false;
429
+ }
430
+
431
+ function collectChangedStyleKeys(
432
+ previousStyle: CanonicalStyle,
433
+ nextStyle: CanonicalStyle,
434
+ ): Array<keyof CanonicalStyle> {
435
+ if (previousStyle === nextStyle) {
436
+ return [];
437
+ }
438
+
439
+ const changed: Array<keyof CanonicalStyle> = [];
440
+
441
+ for (const key of Object.keys(previousStyle) as Array<keyof CanonicalStyle>) {
442
+ if (!canonicalStyleValuesEqual(previousStyle[key], nextStyle[key])) {
443
+ changed.push(key);
444
+ }
445
+ }
446
+
447
+ for (const key of Object.keys(nextStyle) as Array<keyof CanonicalStyle>) {
448
+ if (
449
+ !(key in previousStyle) &&
450
+ !canonicalStyleValuesEqual(previousStyle[key], nextStyle[key])
451
+ ) {
452
+ changed.push(key);
453
+ }
454
+ }
455
+
456
+ return changed;
457
+ }
458
+
459
+ function styleKeyListIncludes(list: string, key: keyof CanonicalStyle): boolean {
460
+ return list.includes(`|${key}|`);
461
+ }
462
+
463
+ function isTransformPatchStyleKey(key: keyof CanonicalStyle): boolean {
464
+ return (
465
+ key === 'transformX' ||
466
+ key === 'transformY' ||
467
+ key === 'transformScale' ||
468
+ key === 'transformRotate'
469
+ );
470
+ }
471
+
472
+ function isPaintPatchStyleKey(key: keyof CanonicalStyle): boolean {
473
+ return key === 'backgroundColor' || key === 'opacity';
474
+ }
475
+
476
+ function styleKeysAffectLayout(changedKeys: readonly (keyof CanonicalStyle)[]): boolean {
477
+ for (const key of changedKeys) {
478
+ if (styleKeyListIncludes(LAYOUT_AFFECTING_STYLE_KEYS, key)) {
479
+ return true;
480
+ }
481
+ }
482
+
483
+ return false;
484
+ }
485
+
486
+ function encodeStyleUpdate(
487
+ encoder: ProtocolEncoder,
488
+ nodeId: number,
489
+ changedKeys: readonly (keyof CanonicalStyle)[],
490
+ nextStyle: CanonicalStyle,
491
+ ): void {
492
+ const canPatch = changedKeys.every(
493
+ (key) => isTransformPatchStyleKey(key) || isPaintPatchStyleKey(key),
494
+ );
495
+
496
+ if (!canPatch) {
497
+ setStyle(encoder, nodeId, nextStyle);
498
+ return;
499
+ }
500
+
501
+ const transformChanged = changedKeys.some(isTransformPatchStyleKey);
502
+ if (transformChanged) {
503
+ setTransform(
504
+ encoder,
505
+ nodeId,
506
+ nextStyle.transformX ?? 0,
507
+ nextStyle.transformY ?? 0,
508
+ nextStyle.transformScale ?? 1,
509
+ nextStyle.transformRotate ?? 0,
510
+ );
511
+ }
512
+
513
+ if (changedKeys.includes('opacity')) {
514
+ setOpacity(encoder, nodeId, nextStyle.opacity ?? 1);
515
+ }
516
+
517
+ if (changedKeys.includes('backgroundColor')) {
518
+ setBackgroundColor(encoder, nodeId, nextStyle.backgroundColor ?? TRANSPARENT_COLOR);
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Merge native defaults, className styles, and explicit inline style into a
524
+ * single canonical style object.
525
+ *
526
+ * The ordering matters:
527
+ * - renderer defaults are the lowest-priority baseline
528
+ * - `className` should be able to override those defaults
529
+ * - inline `style` must still beat `className`
530
+ *
531
+ * To preserve that ordering, inline styles are normalized without injecting the
532
+ * renderer defaults. We add the defaults as a separate first layer instead.
533
+ */
534
+ export function resolveCanonicalStyleFromProps(
535
+ props: Record<string, unknown>,
536
+ defaultFlexDirection: FlexDirection,
537
+ classNameContext?: ClassNameResolutionContext,
538
+ isRNStyle = false,
539
+ ): CanonicalStyle {
540
+ return {
541
+ ...buildDefaultCanonicalStyle(defaultFlexDirection, isRNStyle),
542
+ ...resolveClassNameStyle(props.className, classNameContext ?? { props }),
543
+ ...normalizeStyle(props.style as FullStyle | undefined, 'row'),
544
+ };
545
+ }
546
+
547
+ // =============================================================================
548
+ // Protocol Encoder Instances (Multi-Root)
549
+ // =============================================================================
550
+
551
+ /**
552
+ * Per-root protocol encoders.
553
+ * Each root gets its own encoder that targets a specific root_id.
554
+ */
555
+ const encoders = new Map<number, ProtocolEncoder>();
556
+
557
+ /**
558
+ * Claim a root ID for a public adapter surface.
559
+ *
560
+ * Multiple roots can be owned by the same adapter name, but a single root ID
561
+ * cannot be shared across adapters or handles at the same time.
562
+ */
563
+ export function claimRoot(rootId: number, owner: string): void {
564
+ const existingOwner = rootOwners.get(rootId);
565
+ if (existingOwner && existingOwner !== owner) {
566
+ throw new Error(
567
+ `Root ${rootId} is already in use by ${existingOwner}; ${owner} cannot claim it.`,
568
+ );
569
+ }
570
+
571
+ rootOwners.set(rootId, owner);
572
+ }
573
+
574
+ /**
575
+ * Release a previously claimed root ID.
576
+ *
577
+ * The owner check is intentionally strict so one adapter cannot accidentally
578
+ * release another adapter's root claim during teardown.
579
+ */
580
+ export function releaseRoot(rootId: number, owner: string): void {
581
+ const existingOwner = rootOwners.get(rootId);
582
+ if (!existingOwner) {
583
+ return;
584
+ }
585
+
586
+ if (existingOwner !== owner) {
587
+ throw new Error(
588
+ `Root ${rootId} is owned by ${existingOwner}; ${owner} cannot release it.`,
589
+ );
590
+ }
591
+
592
+ rootOwners.delete(rootId);
593
+ }
594
+
595
+ /**
596
+ * Read the current root owner for diagnostics and tests.
597
+ */
598
+ export function getRootOwner(rootId: number): string | undefined {
599
+ return rootOwners.get(rootId);
600
+ }
601
+
602
+ /**
603
+ * Get or create the encoder for a given root ID.
604
+ */
605
+ export function getEncoderForRoot(rootId: number): ProtocolEncoder {
606
+ let enc = encoders.get(rootId);
607
+ if (!enc) {
608
+ enc = createProtocolEncoder(1024 * 1024, rootId);
609
+ encoders.set(rootId, enc);
610
+ }
611
+ return enc;
612
+ }
613
+
614
+ /**
615
+ * Get the default encoder (root 0) for backwards compatibility.
616
+ */
617
+ export function getEncoder(): ProtocolEncoder {
618
+ return getEncoderForRoot(0);
619
+ }
620
+
621
+ /**
622
+ * Get the shared encoder instance.
623
+ * @internal For testing only.
624
+ * @deprecated Use getEncoder() instead
625
+ */
626
+ export function _getEncoder(): ProtocolEncoder {
627
+ return getEncoderForRoot(0);
628
+ }
629
+
630
+ /**
631
+ * Get the rootId for a node by walking up to the root.
632
+ * Falls back to 0 if no root is found.
633
+ */
634
+ function getRootIdForNode(node: ElementNode | TextNode | RootNode): number {
635
+ let current: ElementNode | TextNode | RootNode | null = node;
636
+ while (current) {
637
+ if (current.kind === NodeKind.Root) {
638
+ return (current as RootNode).rootId;
639
+ }
640
+ current = current.parent as ElementNode | TextNode | RootNode | null;
641
+ }
642
+ return 0;
643
+ }
644
+
645
+ /**
646
+ * Walk up to the root container for a node.
647
+ *
648
+ * The DOM shim and adapter tests need the actual root object, not just the
649
+ * numeric root ID, so host-ops exposes the full lookup helper as part of the
650
+ * shared adapter contract.
651
+ */
652
+ export function getRootContainerForNode(
653
+ node: ElementNode | TextNode | RootNode | null | undefined,
654
+ ): RootNode | null {
655
+ let current = node;
656
+ while (current) {
657
+ if (current.kind === NodeKind.Root) {
658
+ return current;
659
+ }
660
+ current = current.parent as ElementNode | TextNode | RootNode | null;
661
+ }
662
+ return null;
663
+ }
664
+
665
+ /**
666
+ * Get the encoder for a node based on its root.
667
+ */
668
+ function getEncoderForNode(node: ElementNode | TextNode | RootNode): ProtocolEncoder {
669
+ return getEncoderForRoot(getRootIdForNode(node));
670
+ }
671
+
672
+ function getPendingCommitState(rootId: number): PendingCommitState {
673
+ let state = pendingCommitStateByRoot.get(rootId);
674
+ if (!state) {
675
+ state = {
676
+ layoutDirty: false,
677
+ warningAnalysisDirty: false,
678
+ dirtyChildren: new Set<RootNode | ElementNode>(),
679
+ changedViews: new Set<number>(),
680
+ };
681
+ pendingCommitStateByRoot.set(rootId, state);
682
+ }
683
+ return state;
684
+ }
685
+
686
+ function markLayoutDirtyForRoot(rootId: number): void {
687
+ getPendingCommitState(rootId).layoutDirty = true;
688
+ }
689
+
690
+ function markLayoutDirtyForNode(node: ElementNode | TextNode | RootNode): void {
691
+ markLayoutDirtyForRoot(getRootIdForNode(node));
692
+ }
693
+
694
+ function markViewChanged(node: ElementNode | TextNode | RootNode): void {
695
+ getPendingCommitState(getRootIdForNode(node)).changedViews.add(node.id);
696
+ }
697
+
698
+ function markChildrenDirty(parent: RootNode | ElementNode): void {
699
+ const state = getPendingCommitState(getRootIdForNode(parent));
700
+ state.dirtyChildren.add(parent);
701
+ state.layoutDirty = true;
702
+ state.warningAnalysisDirty = true;
703
+ state.changedViews.add(parent.id);
704
+ }
705
+
706
+ function flushDirtyChildren(
707
+ encoder: ProtocolEncoder,
708
+ state: PendingCommitState | undefined,
709
+ ): void {
710
+ if (!state || state.dirtyChildren.size === 0) {
711
+ return;
712
+ }
713
+
714
+ for (const node of state.dirtyChildren) {
715
+ if (node.kind !== NodeKind.Root && node.parent === null) {
716
+ continue;
717
+ }
718
+ encodeChildren(encoder, node);
719
+ node.dirtyFlags = (node.dirtyFlags & ~DirtyFlags.Children) as DirtyFlags;
720
+ }
721
+
722
+ state.dirtyChildren.clear();
723
+ }
724
+
725
+ function ensureLocaleRootSync(): void {
726
+ if (localeChangeUnsubscribe) {
727
+ return;
728
+ }
729
+
730
+ localeChangeUnsubscribe = subscribeExactLocaleChanges(syncLiveRootInheritedState);
731
+ }
732
+
733
+ function ensureAccessibilityRootSync(): void {
734
+ if (accessibilityChangeUnsubscribe) {
735
+ return;
736
+ }
737
+
738
+ accessibilityChangeUnsubscribe = subscribeExactAccessibilityChanges(syncLiveRootInheritedState);
739
+ }
740
+
741
+ function maybeDisposeLocaleRootSync(): void {
742
+ if (liveRoots.size > 0 || !localeChangeUnsubscribe) {
743
+ return;
744
+ }
745
+
746
+ localeChangeUnsubscribe();
747
+ localeChangeUnsubscribe = null;
748
+ }
749
+
750
+ function maybeDisposeAccessibilityRootSync(): void {
751
+ if (liveRoots.size > 0 || !accessibilityChangeUnsubscribe) {
752
+ return;
753
+ }
754
+
755
+ accessibilityChangeUnsubscribe();
756
+ accessibilityChangeUnsubscribe = null;
757
+ }
758
+
759
+ // =============================================================================
760
+ // Event Handler Registry
761
+ // =============================================================================
762
+
763
+ /**
764
+ * Global event handler registry.
765
+ * Maps handler IDs to handler functions.
766
+ * Shared across all frameworks (React, Solid, Vue).
767
+ */
768
+ interface HostEventRegistry {
769
+ handlers: Map<number, Function>;
770
+ nextHandlerId: number;
771
+ }
772
+
773
+ const HOST_EVENT_REGISTRY_KEY = '__exactHostEventRegistry';
774
+
775
+ function getHostEventRegistry(): HostEventRegistry {
776
+ const scope = globalThis as Record<string, unknown>;
777
+ const existing = scope[HOST_EVENT_REGISTRY_KEY];
778
+ if (
779
+ existing &&
780
+ typeof existing === 'object' &&
781
+ (existing as Partial<HostEventRegistry>).handlers instanceof Map &&
782
+ typeof (existing as Partial<HostEventRegistry>).nextHandlerId === 'number'
783
+ ) {
784
+ return existing as HostEventRegistry;
785
+ }
786
+
787
+ const registry: HostEventRegistry = {
788
+ handlers: new Map<number, Function>(),
789
+ nextHandlerId: 1,
790
+ };
791
+ scope[HOST_EVENT_REGISTRY_KEY] = registry;
792
+ return registry;
793
+ }
794
+
795
+ const eventRegistry = getHostEventRegistry();
796
+ const eventHandlers = eventRegistry.handlers;
797
+ type NativeEventBatcher = (callback: () => void) => void;
798
+ let nativeEventBatcher: NativeEventBatcher | null = null;
799
+
800
+ export function setNativeEventBatcher(batcher: NativeEventBatcher | null): void {
801
+ nativeEventBatcher = batcher;
802
+ }
803
+
804
+
805
+ /**
806
+ * Register an event handler and return its ID.
807
+ */
808
+ export function registerHandler(fn: Function): number {
809
+ const id = eventRegistry.nextHandlerId++;
810
+ eventHandlers.set(id, fn);
811
+ return id;
812
+ }
813
+
814
+ /**
815
+ * Unregister an event handler by ID and notify native.
816
+ *
817
+ * @param id - The handler ID to unregister
818
+ * @param nodeId - The node ID to unbind from (optional, for native unbinding)
819
+ * @param eventType - The event type to unbind (optional, for native unbinding)
820
+ * @param rootId - The owning root for the node/event pair. Multi-root adapters
821
+ * must supply the real root so unbind ops land on the correct encoder.
822
+ */
823
+ export function unregisterHandler(
824
+ id: number,
825
+ nodeId?: number,
826
+ eventType?: EventType,
827
+ rootId: number = 0,
828
+ ): void {
829
+ eventHandlers.delete(id);
830
+
831
+ // Send unbind to native if we have the node and event info
832
+ if (nodeId !== undefined && eventType !== undefined) {
833
+ getEncoderForRoot(rootId).unbindEvent(nodeId, eventType);
834
+ }
835
+ }
836
+
837
+ /**
838
+ * Get an event handler by ID.
839
+ */
840
+ export function getHandler(id: number): Function | undefined {
841
+ return eventHandlers.get(id);
842
+ }
843
+
844
+ /**
845
+ * Update an existing handler's function without changing its ID.
846
+ * Used when a framework updates a callback prop.
847
+ */
848
+ export function updateHandler(id: number, fn: Function): void {
849
+ if (eventHandlers.has(id)) {
850
+ eventHandlers.set(id, fn);
851
+ }
852
+ }
853
+
854
+ /**
855
+ * Clear all registered handlers.
856
+ * @internal For testing only.
857
+ */
858
+ export function _clearHandlers(): void {
859
+ eventHandlers.clear();
860
+ eventRegistry.nextHandlerId = 1;
861
+ }
862
+
863
+ // =============================================================================
864
+ // Event Dispatch (called from native)
865
+ // =============================================================================
866
+
867
+ /**
868
+ * Handle an event dispatched from native.
869
+ * Looks up the handler and calls it with the payload.
870
+ * Framework reactivity and the renderer commit lifecycle handle any
871
+ * subsequent updates.
872
+ */
873
+ function handleNativeEvent(handlerId: number, payload: unknown): void {
874
+ const handler = getHandler(handlerId);
875
+ if (!handler) {
876
+ return;
877
+ }
878
+
879
+ if (process.env.NODE_ENV !== 'production') {
880
+ recordInboundProtocolEvent(handlerId, payload);
881
+ }
882
+ invokeHandler(handlerId, handler, payload);
883
+ }
884
+
885
+ function invokeHandler(handlerId: number, handler: Function, payload: unknown): void {
886
+ const invoke = (): void => {
887
+ try {
888
+ handler(payload);
889
+ } catch (error) {
890
+ console.error(`[HostOps] Error in event handler ${handlerId}:`, error);
891
+ }
892
+ };
893
+
894
+ if (nativeEventBatcher) {
895
+ try {
896
+ nativeEventBatcher(invoke);
897
+ } catch (error) {
898
+ console.error(`[HostOps] Error in native event batcher for handler ${handlerId}:`, error);
899
+ }
900
+ return;
901
+ }
902
+
903
+ invoke();
904
+ }
905
+
906
+ export function dispatchSyntheticEvent(
907
+ instance: ElementNode,
908
+ eventType: EventType,
909
+ payload: unknown,
910
+ ): boolean {
911
+ const binding = instance.events.get(eventType);
912
+ if (!binding) {
913
+ return false;
914
+ }
915
+
916
+ invokeHandler(binding.handlerId, binding.handler, payload);
917
+ return true;
918
+ }
919
+
920
+ // Install the global event handler (shared across all frameworks)
921
+ // Always overwrite to ensure we have the correct handler
922
+ (globalThis as Record<string, unknown>).__exactDispatchEvent = handleNativeEvent;
923
+
924
+ // =============================================================================
925
+ // Node Operations
926
+ // =============================================================================
927
+
928
+ // Re-export node operations for convenience
929
+ export {
930
+ NodeKind,
931
+ createElementNode,
932
+ createTextNode,
933
+ createRootNode,
934
+ nodeAppendChild,
935
+ nodeInsertBefore,
936
+ nodeRemoveChild,
937
+ nodeClearChildren,
938
+ };
939
+
940
+ // Re-export types
941
+ export type { ElementNode, TextNode, RootNode };
942
+
943
+ // =============================================================================
944
+ // Tag Configuration
945
+ // =============================================================================
946
+
947
+ export { getTagConfig, defaultTagConfig };
948
+ export type { TagConfig };
949
+
950
+ // =============================================================================
951
+ // Style and Props Utilities
952
+ // =============================================================================
953
+
954
+ export { normalizeStyle, normalizeTransition, stylesEqual, transitionsEqual };
955
+ export { normalizeProps, propsEqual };
956
+
957
+ // =============================================================================
958
+ // Protocol Encoding
959
+ // =============================================================================
960
+
961
+ export {
962
+ encodeCreateElement,
963
+ encodeCreateText,
964
+ encodeProps,
965
+ encodeEvents,
966
+ encodeChildren,
967
+ encodeDestroy,
968
+ };
969
+
970
+ export { EventType, NodeType };
971
+
972
+ // =============================================================================
973
+ // Event Processing Helper
974
+ // =============================================================================
975
+
976
+ type Props = Record<string, unknown>;
977
+
978
+ const PRESS_EVENT_BINDINGS = [
979
+ { eventType: EventType.Press, propName: 'onPress' },
980
+ { eventType: EventType.PressIn, propName: 'onPressIn' },
981
+ { eventType: EventType.PressOut, propName: 'onPressOut' },
982
+ { eventType: EventType.LongPress, propName: 'onLongPress' },
983
+ ] as const;
984
+
985
+ const FOCUS_AND_KEYBOARD_BINDINGS = [
986
+ { eventType: EventType.Focus, propName: 'onFocus', normalize: normalizeFocusChangePayload },
987
+ { eventType: EventType.Blur, propName: 'onBlur', normalize: normalizeFocusChangePayload },
988
+ { eventType: EventType.KeyDown, propName: 'onKeyDown', normalize: normalizeKeyPayload },
989
+ { eventType: EventType.KeyUp, propName: 'onKeyUp', normalize: normalizeKeyPayload },
990
+ { eventType: EventType.FocusIn, propName: 'onFocusIn', normalize: normalizeFocusChangePayload },
991
+ { eventType: EventType.FocusOut, propName: 'onFocusOut', normalize: normalizeFocusChangePayload },
992
+ {
993
+ eventType: EventType.AccessibilityAction,
994
+ propName: 'onAccessibilityAction',
995
+ normalize: normalizeAccessibilityActionPayload,
996
+ },
997
+ ] as const;
998
+
999
+ const EVENT_PROP_NAMES = [
1000
+ 'onPress',
1001
+ 'onPressIn',
1002
+ 'onPressOut',
1003
+ 'onLongPress',
1004
+ 'onValueChange',
1005
+ 'onChangeText',
1006
+ 'onChange',
1007
+ 'onSelectionChange',
1008
+ 'onFocus',
1009
+ 'onBlur',
1010
+ 'onKeyDown',
1011
+ 'onKeyUp',
1012
+ 'onSubmitEditing',
1013
+ 'onFocusIn',
1014
+ 'onFocusOut',
1015
+ 'onAccessibilityAction',
1016
+ 'onTransitionEnd',
1017
+ 'onScroll',
1018
+ 'scrollEventThrottle',
1019
+ 'onLoadStart',
1020
+ 'onLoad',
1021
+ 'onError',
1022
+ 'onDisplay',
1023
+ ] as const;
1024
+
1025
+ function parseRequestedDirection(value: unknown): Direction | undefined {
1026
+ return value === 'ltr' || value === 'rtl' || value === 'auto'
1027
+ ? value
1028
+ : undefined;
1029
+ }
1030
+
1031
+ function parseRequestedWritingMode(value: unknown): WritingMode | undefined {
1032
+ return value === 'horizontal-tb' || value === 'vertical-rl' || value === 'vertical-lr'
1033
+ ? value
1034
+ : undefined;
1035
+ }
1036
+
1037
+ function getRootInheritedState(rootId: number = 0): InheritedElementState {
1038
+ const locale = getExactLocaleSnapshot();
1039
+ return {
1040
+ rootId,
1041
+ direction: locale.direction,
1042
+ writingMode: 'horizontal-tb',
1043
+ lang: locale.tag,
1044
+ safeArea: createRootSafeAreaPropagation(rootId),
1045
+ };
1046
+ }
1047
+
1048
+ function getInheritedState(
1049
+ parent: ElementNode | RootNode | null | undefined,
1050
+ ): InheritedElementState {
1051
+ if (parent?.kind === NodeKind.Element) {
1052
+ return {
1053
+ rootId: parent.propagatedSafeArea?.rootId ?? getRootIdForNode(parent),
1054
+ direction: parent.resolvedDirection,
1055
+ writingMode: parent.resolvedWritingMode,
1056
+ lang: parent.resolvedLang,
1057
+ safeArea: parent.propagatedSafeArea ?? createRootSafeAreaPropagation(getRootIdForNode(parent)),
1058
+ };
1059
+ }
1060
+
1061
+ return getRootInheritedState(parent?.kind === NodeKind.Root ? parent.rootId : 0);
1062
+ }
1063
+
1064
+ function readInlineStyleValue(props: Props, key: string): unknown {
1065
+ const style = props.style;
1066
+ if (!style || typeof style !== 'object') {
1067
+ return undefined;
1068
+ }
1069
+
1070
+ return (style as Record<string, unknown>)[key];
1071
+ }
1072
+
1073
+ function getRequestedDirection(
1074
+ props: Props,
1075
+ classNameStyle: CanonicalStyle,
1076
+ ): Direction | undefined {
1077
+ return parseRequestedDirection(readInlineStyleValue(props, 'direction')) ?? classNameStyle.direction;
1078
+ }
1079
+
1080
+ function getRequestedWritingMode(
1081
+ props: Props,
1082
+ classNameStyle: CanonicalStyle,
1083
+ ): WritingMode | undefined {
1084
+ return parseRequestedWritingMode(readInlineStyleValue(props, 'writingMode')) ?? classNameStyle.writingMode;
1085
+ }
1086
+
1087
+ function getAutoDirectionText(props: CanonicalProps): string | undefined {
1088
+ return props.textContent ?? props.value;
1089
+ }
1090
+
1091
+ function hasStableTextLayoutHint(props: Props): boolean {
1092
+ return props.__exactTextLayoutStable === true;
1093
+ }
1094
+
1095
+ function hasFixedPointTextLayout(style: CanonicalStyle): boolean {
1096
+ return (
1097
+ style.width?.type === 'points' &&
1098
+ isFinite(style.width.value) &&
1099
+ style.height?.type === 'points' &&
1100
+ isFinite(style.height.value)
1101
+ );
1102
+ }
1103
+
1104
+ function canPatchElementTextWithoutLayout(instance: ElementNode, props: Props): boolean {
1105
+ return (
1106
+ instance.tagType === 'text' &&
1107
+ hasStableTextLayoutHint(props) &&
1108
+ hasFixedPointTextLayout(instance.style)
1109
+ );
1110
+ }
1111
+
1112
+ /**
1113
+ * Style fields the kernel reads when measuring a raw text leaf (the
1114
+ * TextMeasureCallback in kernel ffi.rs). A raw text leaf is a separate
1115
+ * kernel node from its styled parent <Text> element and the kernel does not
1116
+ * inherit styles across nodes, so this subset must be encoded onto the leaf
1117
+ * explicitly or the leaf measures with the default font while the host
1118
+ * renders the parent's — adapters that mount text as child leaves (Contract,
1119
+ * Solid) get boxes sized for the wrong font.
1120
+ */
1121
+ const TEXT_LEAF_MEASURE_STYLE_KEYS = [
1122
+ 'fontSize',
1123
+ 'fontWeight',
1124
+ 'fontStyle',
1125
+ 'fontFamily',
1126
+ 'lineHeight',
1127
+ 'letterSpacing',
1128
+ 'fontVariantNumeric',
1129
+ 'direction',
1130
+ 'numberOfLines',
1131
+ ] as const satisfies readonly (keyof CanonicalStyle)[];
1132
+
1133
+ function textMeasureStyleSubset(style: CanonicalStyle): CanonicalStyle | null {
1134
+ let subset: Record<string, unknown> | null = null;
1135
+ for (const key of TEXT_LEAF_MEASURE_STYLE_KEYS) {
1136
+ const value = style[key];
1137
+ if (value !== undefined) {
1138
+ (subset ??= {})[key] = value;
1139
+ }
1140
+ }
1141
+ return subset as CanonicalStyle | null;
1142
+ }
1143
+
1144
+ function mergeTextMeasureStyles(
1145
+ inheritedStyle: CanonicalStyle | null,
1146
+ ownStyle: CanonicalStyle | null,
1147
+ ): CanonicalStyle | null {
1148
+ if (!inheritedStyle) {
1149
+ return ownStyle;
1150
+ }
1151
+ if (!ownStyle) {
1152
+ return inheritedStyle;
1153
+ }
1154
+ return {
1155
+ ...inheritedStyle,
1156
+ ...ownStyle,
1157
+ };
1158
+ }
1159
+
1160
+ function inheritedTextMeasureStyleForNode(node: ElementNode): CanonicalStyle | null {
1161
+ const ancestors: ElementNode[] = [];
1162
+ let cursor: ElementNode | RootNode | TextNode | null = node.parent as
1163
+ | ElementNode
1164
+ | RootNode
1165
+ | TextNode
1166
+ | null;
1167
+ while (cursor?.kind === NodeKind.Element) {
1168
+ const ancestor = cursor as ElementNode;
1169
+ ancestors.unshift(ancestor);
1170
+ cursor = ancestor.parent as ElementNode | RootNode | TextNode | null;
1171
+ }
1172
+
1173
+ let inheritedStyle: CanonicalStyle | null = null;
1174
+ let inInlineTextContext = false;
1175
+ for (const ancestor of ancestors) {
1176
+ if (ancestor.tagType === 'text') {
1177
+ inheritedStyle = mergeTextMeasureStyles(
1178
+ inheritedStyle,
1179
+ textMeasureStyleSubset(ancestor.style),
1180
+ );
1181
+ inInlineTextContext = true;
1182
+ } else if (inInlineTextContext && ancestor.tagType === 'pressable') {
1183
+ continue;
1184
+ } else {
1185
+ inheritedStyle = null;
1186
+ inInlineTextContext = false;
1187
+ }
1188
+ }
1189
+
1190
+ return inheritedStyle;
1191
+ }
1192
+
1193
+ function effectiveTextMeasureStyleForNode(
1194
+ node: ElementNode,
1195
+ inheritedStyle: CanonicalStyle | null,
1196
+ ): CanonicalStyle | null {
1197
+ return mergeTextMeasureStyles(inheritedStyle, textMeasureStyleSubset(node.style));
1198
+ }
1199
+
1200
+ function inheritedTextMeasureStyleForChild(
1201
+ child: ElementNode | TextNode,
1202
+ inheritedStyle: CanonicalStyle | null,
1203
+ ): CanonicalStyle | null {
1204
+ if (child.kind !== NodeKind.Element) {
1205
+ return inheritedStyle;
1206
+ }
1207
+
1208
+ if (child.tagType === 'text') {
1209
+ return effectiveTextMeasureStyleForNode(child, inheritedStyle);
1210
+ }
1211
+
1212
+ return inheritedStyle && child.tagType === 'pressable' ? inheritedStyle : null;
1213
+ }
1214
+
1215
+ function inheritedTextMeasureStyleForInsertedChildren(
1216
+ parent: ElementNode | RootNode,
1217
+ ): CanonicalStyle | null {
1218
+ if (parent.kind !== NodeKind.Element) {
1219
+ return null;
1220
+ }
1221
+
1222
+ const inheritedStyle = inheritedTextMeasureStyleForNode(parent);
1223
+ if (parent.tagType === 'text') {
1224
+ return effectiveTextMeasureStyleForNode(parent, inheritedStyle);
1225
+ }
1226
+
1227
+ return inheritedStyle && parent.tagType === 'pressable' ? inheritedStyle : null;
1228
+ }
1229
+
1230
+ function styleKeysAffectTextLeafMeasure(
1231
+ changedKeys: readonly (keyof CanonicalStyle)[],
1232
+ ): boolean {
1233
+ return changedKeys.some((key) =>
1234
+ (TEXT_LEAF_MEASURE_STYLE_KEYS as readonly string[]).includes(key),
1235
+ );
1236
+ }
1237
+
1238
+ /** Re-encode the measure-style subset onto already-created text leaf children. */
1239
+ function encodeTextLeafMeasureStyles(encoder: ProtocolEncoder, parent: ElementNode): void {
1240
+ const inheritedStyle = inheritedTextMeasureStyleForNode(parent);
1241
+ const parentMeasureStyle = effectiveTextMeasureStyleForNode(parent, inheritedStyle);
1242
+ if (!parentMeasureStyle) {
1243
+ return;
1244
+ }
1245
+
1246
+ if (inheritedStyle) {
1247
+ setStyle(encoder, parent.id, parentMeasureStyle);
1248
+ }
1249
+
1250
+ for (const child of parent.children) {
1251
+ encodeInheritedTextMeasureStyles(encoder, child, parentMeasureStyle);
1252
+ }
1253
+ }
1254
+
1255
+ function encodeInheritedTextMeasureStyles(
1256
+ encoder: ProtocolEncoder,
1257
+ node: ElementNode | TextNode,
1258
+ inheritedStyle: CanonicalStyle | null,
1259
+ ): void {
1260
+ if (!inheritedStyle || hasDirtyFlag(node, DirtyFlags.Created)) {
1261
+ return;
1262
+ }
1263
+
1264
+ if (node.kind === NodeKind.Text) {
1265
+ setStyle(encoder, node.id, inheritedStyle);
1266
+ return;
1267
+ }
1268
+
1269
+ const nodeMeasureStyle = inheritedTextMeasureStyleForChild(node, inheritedStyle);
1270
+ if (!nodeMeasureStyle) {
1271
+ return;
1272
+ }
1273
+
1274
+ if (node.tagType === 'text') {
1275
+ setStyle(encoder, node.id, nodeMeasureStyle);
1276
+ }
1277
+
1278
+ for (const child of node.children) {
1279
+ encodeInheritedTextMeasureStyles(encoder, child, nodeMeasureStyle);
1280
+ }
1281
+ }
1282
+
1283
+ function canPatchTextNodeWithoutLayout(instance: TextNode): boolean {
1284
+ const parent = instance.parent;
1285
+ if (parent?.kind !== NodeKind.Element) {
1286
+ return false;
1287
+ }
1288
+ const elementParent = parent as ElementNode;
1289
+
1290
+ return (
1291
+ elementParent.tagType === 'text' &&
1292
+ hasStableTextLayoutHint(elementParent.originalProps) &&
1293
+ hasFixedPointTextLayout(elementParent.style)
1294
+ );
1295
+ }
1296
+
1297
+ function serializeNativeViewProps(
1298
+ config: TagConfig,
1299
+ props: Props,
1300
+ ): string {
1301
+ const metadata = config.nativeView;
1302
+ if (!metadata) {
1303
+ return '{}';
1304
+ }
1305
+
1306
+ const payload: Record<string, unknown> = {};
1307
+ for (const key of metadata.propKeys) {
1308
+ if (props[key] !== undefined) {
1309
+ payload[key] = props[key];
1310
+ }
1311
+ }
1312
+
1313
+ try {
1314
+ return JSON.stringify(payload);
1315
+ } catch (error) {
1316
+ if (__DEV__) {
1317
+ console.warn(
1318
+ `[HostOps] Failed to serialize native-view props for ${metadata.moduleName}:`,
1319
+ error,
1320
+ );
1321
+ }
1322
+ return '{}';
1323
+ }
1324
+ }
1325
+
1326
+ function augmentCanonicalPropsForNativeView(
1327
+ config: TagConfig,
1328
+ props: Props,
1329
+ canonicalProps: CanonicalProps,
1330
+ ): CanonicalProps {
1331
+ const metadata = config.nativeView;
1332
+ if (!metadata) {
1333
+ return canonicalProps;
1334
+ }
1335
+
1336
+ return {
1337
+ ...canonicalProps,
1338
+ nativeViewModuleName: metadata.moduleName,
1339
+ nativeViewProps: serializeNativeViewProps(config, props),
1340
+ nativeViewSelectionTier: metadata.selection?.tier,
1341
+ nativeViewGesturePolicy: metadata.selection?.gesturePolicy,
1342
+ };
1343
+ }
1344
+
1345
+ function resolveDirectionForElement(
1346
+ originalTag: string,
1347
+ tagType: CanonicalTagType,
1348
+ requestedDirection: Direction | undefined,
1349
+ props: CanonicalProps,
1350
+ inherited: InheritedElementState,
1351
+ ): ResolvedDirection {
1352
+ if (requestedDirection === 'ltr' || requestedDirection === 'rtl') {
1353
+ return requestedDirection;
1354
+ }
1355
+
1356
+ if (requestedDirection !== 'auto') {
1357
+ return inherited.direction;
1358
+ }
1359
+
1360
+ const text = getAutoDirectionText(props);
1361
+ if (text !== undefined) {
1362
+ return detectTextDirection(text, inherited.direction);
1363
+ }
1364
+
1365
+ if (__DEV__ && !warnedAutoDirectionTags.has(originalTag)) {
1366
+ warnedAutoDirectionTags.add(originalTag);
1367
+ console.warn(
1368
+ `[HostOps] direction="auto" requires text content; falling back to inherited direction for <${originalTag}> (${tagType}).`,
1369
+ );
1370
+ }
1371
+
1372
+ return inherited.direction;
1373
+ }
1374
+
1375
+ let resolveElementStateCallCount = 0;
1376
+
1377
+ /**
1378
+ * Number of resolveElementState invocations since the last reset.
1379
+ * @internal For testing only (verifies the updateInstanceProps fast path).
1380
+ */
1381
+ export function _getResolveElementStateCallCount(): number {
1382
+ return resolveElementStateCallCount;
1383
+ }
1384
+
1385
+ /** @internal For testing only. */
1386
+ export function _resetResolveElementStateCallCount(): void {
1387
+ resolveElementStateCallCount = 0;
1388
+ }
1389
+
1390
+ function resolveElementState(
1391
+ instance: ElementNode,
1392
+ config: TagConfig,
1393
+ originalTag: string,
1394
+ props: Props,
1395
+ inherited: InheritedElementState,
1396
+ ): ResolvedElementState {
1397
+ resolveElementStateCallCount += 1;
1398
+ const normalizedCanonicalProps = augmentCanonicalPropsForNativeView(
1399
+ config,
1400
+ props,
1401
+ normalizeProps(config.canonicalType, props, config.isRNStyle),
1402
+ );
1403
+ const paragraph = config.canonicalType === 'text'
1404
+ ? lowerTextPropsToParagraphSpec(props)
1405
+ : null;
1406
+ const canonicalProps =
1407
+ config.canonicalType === 'text'
1408
+ ? {
1409
+ ...normalizedCanonicalProps,
1410
+ textContent: paragraph?.plainText ?? normalizedCanonicalProps.textContent,
1411
+ }
1412
+ : normalizedCanonicalProps;
1413
+ const classNameStyle = resolveClassNameStyle(props.className, { props });
1414
+ const resolvedDirection = resolveDirectionForElement(
1415
+ originalTag,
1416
+ config.canonicalType,
1417
+ getRequestedDirection(props, classNameStyle),
1418
+ canonicalProps,
1419
+ inherited,
1420
+ );
1421
+ const resolvedWritingMode =
1422
+ getRequestedWritingMode(props, classNameStyle) ?? inherited.writingMode;
1423
+ const resolvedLang = canonicalProps.lang ?? inherited.lang;
1424
+ const contentContainerStyle = config.canonicalType === 'scroll'
1425
+ ? normalizeStyle(props.contentContainerStyle as FullStyle | undefined, 'row', {
1426
+ resolvedDirection,
1427
+ resolvedWritingMode,
1428
+ includeInheritedValues: true,
1429
+ })
1430
+ : {};
1431
+ const inlineStyle = normalizeStyle(props.style as FullStyle | undefined, 'row', {
1432
+ resolvedDirection,
1433
+ resolvedWritingMode,
1434
+ includeInheritedValues: true,
1435
+ });
1436
+ const style = applyTextLayoutPropStyleOverrides(
1437
+ config.canonicalType,
1438
+ applyPropDrivenStyleOverrides(
1439
+ config.canonicalType,
1440
+ {
1441
+ ...buildDefaultCanonicalStyle(config.defaultFlexDirection, config.isRNStyle),
1442
+ ...classNameStyle,
1443
+ ...contentContainerStyle,
1444
+ ...inlineStyle,
1445
+ direction: resolvedDirection,
1446
+ writingMode: resolvedWritingMode,
1447
+ },
1448
+ canonicalProps,
1449
+ props,
1450
+ ),
1451
+ props,
1452
+ );
1453
+ const accessibilityAwareStyle = applyRootScrollViewportDefaults(
1454
+ instance,
1455
+ applyAccessibilityDrivenStyleOverrides(style, canonicalProps),
1456
+ );
1457
+
1458
+ const safeAreaResolution = resolveSafeAreaForElement(
1459
+ instance,
1460
+ instance.parent as ElementNode | RootNode | null,
1461
+ accessibilityAwareStyle,
1462
+ inherited.safeArea,
1463
+ );
1464
+
1465
+ if (config.canonicalType === 'scroll' && accessibilityAwareStyle.overflow === undefined) {
1466
+ safeAreaResolution.style.overflow = 'scroll';
1467
+ }
1468
+
1469
+ return {
1470
+ props: canonicalProps,
1471
+ paragraphSpec: paragraph?.spec ?? null,
1472
+ paragraphKey: paragraph?.key ?? null,
1473
+ style: safeAreaResolution.style,
1474
+ transitions: normalizeTransition((props.style as FullStyle | undefined)?.transition),
1475
+ resolvedDirection,
1476
+ resolvedWritingMode,
1477
+ resolvedLang,
1478
+ safeAreaState: safeAreaResolution.safeAreaState,
1479
+ propagatedSafeArea: safeAreaResolution.propagatedSafeArea,
1480
+ };
1481
+ }
1482
+
1483
+ function applyResolvedElementState(
1484
+ instance: ElementNode,
1485
+ state: ResolvedElementState,
1486
+ ): void {
1487
+ instance.props = state.props;
1488
+ instance.paragraphSpec = state.paragraphSpec;
1489
+ instance.paragraphKey = state.paragraphKey;
1490
+ instance.style = state.style;
1491
+ instance.transitions = state.transitions;
1492
+ instance.resolvedDirection = state.resolvedDirection;
1493
+ instance.resolvedWritingMode = state.resolvedWritingMode;
1494
+ instance.resolvedLang = state.resolvedLang;
1495
+ instance.safeAreaState = state.safeAreaState;
1496
+ instance.propagatedSafeArea = state.propagatedSafeArea;
1497
+ }
1498
+
1499
+ function canRefreshCreatedMountEnvironment(
1500
+ instance: ElementNode,
1501
+ inherited: InheritedElementState,
1502
+ ): boolean {
1503
+ if (instance.safeAreaState !== null) {
1504
+ return false;
1505
+ }
1506
+
1507
+ const expectedLang = instance.props.lang ?? inherited.lang;
1508
+ return (
1509
+ instance.tagType !== 'text' &&
1510
+ instance.resolvedDirection === inherited.direction &&
1511
+ instance.resolvedWritingMode === inherited.writingMode &&
1512
+ instance.resolvedLang === expectedLang
1513
+ );
1514
+ }
1515
+
1516
+ function refreshCreatedMountEnvironment(
1517
+ instance: ElementNode,
1518
+ inherited: InheritedElementState,
1519
+ ): void {
1520
+ instance.resolvedLang = instance.props.lang ?? inherited.lang;
1521
+
1522
+ const safeAreaResolution = resolveSafeAreaForElement(
1523
+ instance,
1524
+ instance.parent as ElementNode | RootNode | null,
1525
+ applyRootScrollViewportDefaults(instance, instance.style),
1526
+ inherited.safeArea,
1527
+ );
1528
+ instance.style = safeAreaResolution.style;
1529
+ instance.safeAreaState = safeAreaResolution.safeAreaState;
1530
+ instance.propagatedSafeArea = safeAreaResolution.propagatedSafeArea;
1531
+ }
1532
+
1533
+ interface NormalizedTextChangePayload {
1534
+ nativeEvent: {
1535
+ text: string;
1536
+ selection?: {
1537
+ start: number;
1538
+ end: number;
1539
+ };
1540
+ isComposing?: boolean;
1541
+ compositionRange?: {
1542
+ start: number;
1543
+ end: number;
1544
+ };
1545
+ textRevision?: number;
1546
+ classification?: string;
1547
+ textChanged?: boolean;
1548
+ };
1549
+ }
1550
+
1551
+ type NormalizedImageEventType = 'loadStart' | 'load' | 'error' | 'display';
1552
+
1553
+ interface NormalizedImageEventPayload {
1554
+ event: NormalizedImageEventType;
1555
+ source: {
1556
+ uri: string;
1557
+ width: number;
1558
+ height: number;
1559
+ };
1560
+ cacheType: 'none' | 'disk' | 'memory';
1561
+ error: string;
1562
+ }
1563
+
1564
+ function normalizeTextChangePayload(payload: unknown): NormalizedTextChangePayload {
1565
+ const normalizedSelection = normalizeTextSelectionPayload(payload);
1566
+ const normalizedCompositionRange = normalizeTextRangePayload(
1567
+ payload,
1568
+ 'compositionRange',
1569
+ 'compositionStart',
1570
+ 'compositionEnd',
1571
+ );
1572
+ const isComposing = readTextComposingPayload(payload);
1573
+ const textRevision = readTextRevisionPayload(payload);
1574
+ const classification = readTextClassificationPayload(payload);
1575
+ if (
1576
+ typeof payload === 'object' &&
1577
+ payload !== null &&
1578
+ 'nativeEvent' in payload &&
1579
+ typeof (payload as { nativeEvent?: { text?: unknown } }).nativeEvent?.text === 'string'
1580
+ ) {
1581
+ return {
1582
+ nativeEvent: {
1583
+ ...(payload as NormalizedTextChangePayload).nativeEvent,
1584
+ ...(normalizedSelection ? { selection: normalizedSelection } : {}),
1585
+ ...(isComposing !== undefined ? { isComposing } : {}),
1586
+ ...(normalizedCompositionRange ? { compositionRange: normalizedCompositionRange } : {}),
1587
+ ...(textRevision !== undefined ? { textRevision } : {}),
1588
+ ...(classification !== undefined ? { classification } : {}),
1589
+ textChanged: readTextChangedPayload(payload),
1590
+ },
1591
+ };
1592
+ }
1593
+
1594
+ const textCandidate =
1595
+ typeof payload === 'object' && payload !== null
1596
+ ? (
1597
+ payload as {
1598
+ text?: unknown;
1599
+ value?: unknown;
1600
+ }
1601
+ ).text ??
1602
+ (
1603
+ payload as {
1604
+ text?: unknown;
1605
+ value?: unknown;
1606
+ }
1607
+ ).value
1608
+ : payload;
1609
+
1610
+ return {
1611
+ nativeEvent: {
1612
+ text: typeof textCandidate === 'string' ? textCandidate : '',
1613
+ ...(normalizedSelection ? { selection: normalizedSelection } : {}),
1614
+ ...(isComposing !== undefined ? { isComposing } : {}),
1615
+ ...(normalizedCompositionRange ? { compositionRange: normalizedCompositionRange } : {}),
1616
+ ...(textRevision !== undefined ? { textRevision } : {}),
1617
+ ...(classification !== undefined ? { classification } : {}),
1618
+ textChanged: readTextChangedPayload(payload),
1619
+ },
1620
+ };
1621
+ }
1622
+
1623
+ function readTextComposingPayload(payload: unknown): boolean | undefined {
1624
+ const record = payloadRecord(payload);
1625
+ const nativeEvent = payloadRecord(record.nativeEvent);
1626
+ const value = nativeEvent.isComposing ?? nativeEvent.composing ?? record.isComposing ?? record.composing;
1627
+ return typeof value === 'boolean' ? value : undefined;
1628
+ }
1629
+
1630
+ function readTextChangedPayload(payload: unknown): boolean {
1631
+ const record = payloadRecord(payload);
1632
+ const nativeEvent = payloadRecord(record.nativeEvent);
1633
+ const value = nativeEvent.textChanged ?? record.textChanged;
1634
+ return typeof value === 'boolean' ? value : true;
1635
+ }
1636
+
1637
+ function readTextRevisionPayload(payload: unknown): number | undefined {
1638
+ const record = payloadRecord(payload);
1639
+ const nativeEvent = payloadRecord(record.nativeEvent);
1640
+ const value = nativeEvent.textRevision ?? nativeEvent.revision ?? record.textRevision ?? record.revision;
1641
+ if (typeof value === 'number' && Number.isFinite(value)) {
1642
+ return value;
1643
+ }
1644
+ if (typeof value === 'string') {
1645
+ const parsed = Number(value);
1646
+ return Number.isFinite(parsed) ? parsed : undefined;
1647
+ }
1648
+ return undefined;
1649
+ }
1650
+
1651
+ function readTextClassificationPayload(payload: unknown): string | undefined {
1652
+ const record = payloadRecord(payload);
1653
+ const nativeEvent = payloadRecord(record.nativeEvent);
1654
+ const value =
1655
+ nativeEvent.classification ??
1656
+ nativeEvent.textClassification ??
1657
+ record.classification ??
1658
+ record.textClassification;
1659
+ return typeof value === 'string' ? value : undefined;
1660
+ }
1661
+
1662
+ function normalizeTextSelectionPayload(payload: unknown): { start: number; end: number } | undefined {
1663
+ return normalizeTextRangePayload(payload, 'selection', 'selectionStart', 'selectionEnd');
1664
+ }
1665
+
1666
+ function normalizeTextRangePayload(
1667
+ payload: unknown,
1668
+ rangeKey: string,
1669
+ startKey: string,
1670
+ endKey: string,
1671
+ ): { start: number; end: number } | undefined {
1672
+ const record = payloadRecord(payload);
1673
+ const nativeEvent = payloadRecord(record.nativeEvent);
1674
+ const range = payloadRecord(nativeEvent[rangeKey] ?? record[rangeKey]);
1675
+ const startCandidate = range.start ?? nativeEvent[startKey] ?? record[startKey];
1676
+ const endCandidate = range.end ?? nativeEvent[endKey] ?? record[endKey];
1677
+
1678
+ if (
1679
+ typeof startCandidate !== 'number' ||
1680
+ typeof endCandidate !== 'number' ||
1681
+ !Number.isFinite(startCandidate) ||
1682
+ !Number.isFinite(endCandidate)
1683
+ ) {
1684
+ return undefined;
1685
+ }
1686
+
1687
+ return {
1688
+ start: startCandidate,
1689
+ end: endCandidate,
1690
+ };
1691
+ }
1692
+
1693
+ function payloadRecord(payload: unknown): Record<string, unknown> {
1694
+ return typeof payload === 'object' && payload !== null
1695
+ ? payload as Record<string, unknown>
1696
+ : {};
1697
+ }
1698
+
1699
+ function readEventField(payload: unknown, field: string): unknown {
1700
+ const record = payloadRecord(payload);
1701
+ const nativeEvent = payloadRecord(record.nativeEvent);
1702
+ return nativeEvent[field] ?? record[field];
1703
+ }
1704
+
1705
+ function normalizeImageEventName(value: unknown): NormalizedImageEventType | null {
1706
+ if (typeof value !== 'string') {
1707
+ return null;
1708
+ }
1709
+
1710
+ switch (value.toLowerCase()) {
1711
+ case 'loadstart':
1712
+ case 'load-start':
1713
+ case 'load_start':
1714
+ return 'loadStart';
1715
+ case 'load':
1716
+ return 'load';
1717
+ case 'error':
1718
+ return 'error';
1719
+ case 'display':
1720
+ return 'display';
1721
+ default:
1722
+ return null;
1723
+ }
1724
+ }
1725
+
1726
+ function finiteNumber(value: unknown): number {
1727
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
1728
+ }
1729
+
1730
+ function normalizeImageCacheType(value: unknown): 'none' | 'disk' | 'memory' {
1731
+ return value === 'disk' || value === 'memory' || value === 'none' ? value : 'none';
1732
+ }
1733
+
1734
+ function normalizeImageEventPayload(payload: unknown): NormalizedImageEventPayload | null {
1735
+ const event = normalizeImageEventName(
1736
+ readEventField(payload, 'event') ?? readEventField(payload, 'type'),
1737
+ );
1738
+ if (!event) {
1739
+ return null;
1740
+ }
1741
+
1742
+ const uriCandidate =
1743
+ readEventField(payload, 'uri') ??
1744
+ readEventField(payload, 'src') ??
1745
+ readEventField(payload, 'currentSrc');
1746
+ const errorCandidate =
1747
+ readEventField(payload, 'error') ??
1748
+ readEventField(payload, 'message') ??
1749
+ 'Image failed to load.';
1750
+
1751
+ return {
1752
+ event,
1753
+ source: {
1754
+ uri: typeof uriCandidate === 'string' ? uriCandidate : '',
1755
+ width: finiteNumber(
1756
+ readEventField(payload, 'width') ?? readEventField(payload, 'naturalWidth'),
1757
+ ),
1758
+ height: finiteNumber(
1759
+ readEventField(payload, 'height') ?? readEventField(payload, 'naturalHeight'),
1760
+ ),
1761
+ },
1762
+ cacheType: normalizeImageCacheType(readEventField(payload, 'cacheType')),
1763
+ error: typeof errorCandidate === 'string' ? errorCandidate : 'Image failed to load.',
1764
+ };
1765
+ }
1766
+
1767
+ function dispatchImageEventPayload(props: Props, payload: unknown): void {
1768
+ const event = normalizeImageEventPayload(payload);
1769
+ if (!event) {
1770
+ return;
1771
+ }
1772
+
1773
+ switch (event.event) {
1774
+ case 'loadStart':
1775
+ if (typeof props.onLoadStart === 'function') {
1776
+ props.onLoadStart();
1777
+ }
1778
+ return;
1779
+ case 'load':
1780
+ if (typeof props.onLoad === 'function') {
1781
+ props.onLoad({
1782
+ source: event.source,
1783
+ cacheType: event.cacheType,
1784
+ });
1785
+ }
1786
+ return;
1787
+ case 'error':
1788
+ if (typeof props.onError === 'function') {
1789
+ props.onError({
1790
+ error: event.error,
1791
+ });
1792
+ }
1793
+ return;
1794
+ case 'display':
1795
+ if (typeof props.onDisplay === 'function') {
1796
+ props.onDisplay();
1797
+ }
1798
+ return;
1799
+ }
1800
+ }
1801
+
1802
+ function normalizeFocusChangePayload(payload: unknown): {
1803
+ nativeEvent: {
1804
+ timestamp: number;
1805
+ relatedTarget?: number | null;
1806
+ };
1807
+ } {
1808
+ const relatedTarget =
1809
+ (payload as { nativeEvent?: { relatedTarget?: unknown }; relatedTarget?: unknown } | null)
1810
+ ?.nativeEvent?.relatedTarget ??
1811
+ (payload as { nativeEvent?: { relatedTarget?: unknown }; relatedTarget?: unknown } | null)
1812
+ ?.relatedTarget;
1813
+
1814
+ return {
1815
+ nativeEvent: {
1816
+ timestamp: Date.now(),
1817
+ relatedTarget: typeof relatedTarget === 'number' ? relatedTarget : null,
1818
+ },
1819
+ };
1820
+ }
1821
+
1822
+ function normalizeKeyPayload(payload: unknown): {
1823
+ nativeEvent: {
1824
+ key: string;
1825
+ code?: string;
1826
+ altKey: boolean;
1827
+ ctrlKey: boolean;
1828
+ metaKey: boolean;
1829
+ shiftKey: boolean;
1830
+ repeat: boolean;
1831
+ timestamp: number;
1832
+ };
1833
+ preventDefault?: () => void;
1834
+ defaultPrevented?: boolean;
1835
+ } {
1836
+ const nativeEvent =
1837
+ (payload as {
1838
+ nativeEvent?: {
1839
+ key?: unknown;
1840
+ code?: unknown;
1841
+ altKey?: unknown;
1842
+ ctrlKey?: unknown;
1843
+ metaKey?: unknown;
1844
+ shiftKey?: unknown;
1845
+ repeat?: unknown;
1846
+ timestamp?: unknown;
1847
+ };
1848
+ } | null)?.nativeEvent;
1849
+
1850
+ return {
1851
+ nativeEvent: {
1852
+ key: typeof nativeEvent?.key === 'string' ? nativeEvent.key : '',
1853
+ code: typeof nativeEvent?.code === 'string' ? nativeEvent.code : undefined,
1854
+ altKey: nativeEvent?.altKey === true,
1855
+ ctrlKey: nativeEvent?.ctrlKey === true,
1856
+ metaKey: nativeEvent?.metaKey === true,
1857
+ shiftKey: nativeEvent?.shiftKey === true,
1858
+ repeat: nativeEvent?.repeat === true,
1859
+ timestamp:
1860
+ typeof nativeEvent?.timestamp === 'number' && isFinite(nativeEvent.timestamp)
1861
+ ? nativeEvent.timestamp
1862
+ : Date.now(),
1863
+ },
1864
+ preventDefault:
1865
+ typeof (payload as { preventDefault?: unknown } | null)?.preventDefault === 'function'
1866
+ ? (payload as { preventDefault: () => void }).preventDefault
1867
+ : undefined,
1868
+ defaultPrevented:
1869
+ (payload as { defaultPrevented?: unknown } | null)?.defaultPrevented === true,
1870
+ };
1871
+ }
1872
+
1873
+ function normalizeAccessibilityActionPayload(payload: unknown): {
1874
+ nativeEvent: {
1875
+ actionName: string;
1876
+ };
1877
+ } {
1878
+ const actionNameCandidate =
1879
+ (payload as { nativeEvent?: { actionName?: unknown }; actionName?: unknown } | null)
1880
+ ?.nativeEvent?.actionName ??
1881
+ (payload as { nativeEvent?: { actionName?: unknown }; actionName?: unknown } | null)
1882
+ ?.actionName;
1883
+
1884
+ return {
1885
+ nativeEvent: {
1886
+ actionName:
1887
+ typeof actionNameCandidate === 'string' && actionNameCandidate.length > 0
1888
+ ? actionNameCandidate
1889
+ : '',
1890
+ },
1891
+ };
1892
+ }
1893
+
1894
+ function readFiniteNumber(value: unknown, fallback = 0): number {
1895
+ if (typeof value === 'number' && isFinite(value)) {
1896
+ return value;
1897
+ }
1898
+ if (typeof value === 'string') {
1899
+ const parsed = Number(value);
1900
+ return isFinite(parsed) ? parsed : fallback;
1901
+ }
1902
+ return fallback;
1903
+ }
1904
+
1905
+ function normalizeScrollPayload(payload: unknown): {
1906
+ nativeEvent: {
1907
+ contentOffset: {
1908
+ x: number;
1909
+ y: number;
1910
+ };
1911
+ contentSize: {
1912
+ width: number;
1913
+ height: number;
1914
+ };
1915
+ layoutMeasurement: {
1916
+ width: number;
1917
+ height: number;
1918
+ };
1919
+ timestamp: number;
1920
+ };
1921
+ } {
1922
+ const nativeEvent =
1923
+ (payload as {
1924
+ nativeEvent?: {
1925
+ contentOffset?: {
1926
+ x?: unknown;
1927
+ y?: unknown;
1928
+ };
1929
+ timestamp?: unknown;
1930
+ };
1931
+ } | null)?.nativeEvent;
1932
+ const contentOffset =
1933
+ nativeEvent?.contentOffset ??
1934
+ (payload as {
1935
+ contentOffset?: {
1936
+ x?: unknown;
1937
+ y?: unknown;
1938
+ };
1939
+ } | null)?.contentOffset;
1940
+
1941
+ return {
1942
+ nativeEvent: {
1943
+ contentOffset: {
1944
+ x: readFiniteNumber(contentOffset?.x ?? (payload as { x?: unknown } | null)?.x),
1945
+ y: readFiniteNumber(contentOffset?.y ?? (payload as { y?: unknown } | null)?.y),
1946
+ },
1947
+ contentSize: {
1948
+ width: readFiniteNumber(
1949
+ (payload as { contentSize?: { width?: unknown } } | null)?.contentSize?.width,
1950
+ ),
1951
+ height: readFiniteNumber(
1952
+ (payload as { contentSize?: { height?: unknown } } | null)?.contentSize?.height,
1953
+ ),
1954
+ },
1955
+ layoutMeasurement: {
1956
+ width: readFiniteNumber(
1957
+ (payload as { layoutMeasurement?: { width?: unknown } } | null)?.layoutMeasurement?.width,
1958
+ ),
1959
+ height: readFiniteNumber(
1960
+ (payload as { layoutMeasurement?: { height?: unknown } } | null)?.layoutMeasurement?.height,
1961
+ ),
1962
+ },
1963
+ timestamp: readFiniteNumber(nativeEvent?.timestamp, Date.now()),
1964
+ },
1965
+ };
1966
+ }
1967
+
1968
+ function resolveScrollEventThrottle(props: Props): number {
1969
+ const value = props.scrollEventThrottle;
1970
+ const throttle = typeof value === 'number' && isFinite(value) ? value : 0;
1971
+ return throttle > 0 ? throttle : 0;
1972
+ }
1973
+
1974
+ /**
1975
+ * Per-instance scroll throttle state. Kept outside the onScroll wrapper so an
1976
+ * unrelated event-prop change (which rebuilds the wrapper) does not reset the
1977
+ * throttle window mid-scroll.
1978
+ */
1979
+ const scrollThrottleState = new WeakMap<ElementNode, { lastDispatchAt: number | null }>();
1980
+
1981
+ function getScrollThrottleState(instance: ElementNode): { lastDispatchAt: number | null } {
1982
+ let state = scrollThrottleState.get(instance);
1983
+ if (!state) {
1984
+ state = { lastDispatchAt: null };
1985
+ scrollThrottleState.set(instance, state);
1986
+ }
1987
+ return state;
1988
+ }
1989
+
1990
+ function getElementTestId(instance: ElementNode): string | undefined {
1991
+ const candidate =
1992
+ instance.originalProps.testId ??
1993
+ instance.originalProps.testID ??
1994
+ instance.originalProps['data-testid'] ??
1995
+ instance.originalProps['data-test-id'] ??
1996
+ instance.originalProps.id;
1997
+ return typeof candidate === 'string' && candidate.length > 0
1998
+ ? candidate
1999
+ : undefined;
2000
+ }
2001
+
2002
+ function getElementLabel(instance: ElementNode): string | undefined {
2003
+ if (typeof instance.props.accessibilityLabel === 'string' && instance.props.accessibilityLabel.length > 0) {
2004
+ return instance.props.accessibilityLabel;
2005
+ }
2006
+
2007
+ if (instance.tagType === 'input') {
2008
+ return instance.props.value ?? instance.props.placeholder;
2009
+ }
2010
+
2011
+ return instance.props.textContent;
2012
+ }
2013
+
2014
+ function syncInputFocusTracking(instance: ElementNode, focused: boolean): void {
2015
+ const rootId = getRootIdForNode(instance);
2016
+
2017
+ if (!focused) {
2018
+ clearFocusedInput(rootId, instance.id);
2019
+ clearFocusedTarget();
2020
+ return;
2021
+ }
2022
+
2023
+ noteFocusedInput(rootId, instance.id);
2024
+ setFocusedTarget({
2025
+ viewId: instance.id,
2026
+ type: instance.originalTag,
2027
+ label: getElementLabel(instance),
2028
+ testId: getElementTestId(instance),
2029
+ });
2030
+ }
2031
+
2032
+ function upsertEventHandler(
2033
+ instance: ElementNode,
2034
+ eventType: EventType,
2035
+ handler: Function,
2036
+ ): void {
2037
+ const existing = instance.events.get(eventType);
2038
+ if (existing) {
2039
+ updateHandler(existing.handlerId, handler);
2040
+ existing.handler = handler;
2041
+ return;
2042
+ }
2043
+
2044
+ const handlerId = registerHandler(handler);
2045
+ instance.events.set(eventType, { handlerId, handler });
2046
+ }
2047
+
2048
+ function removeEventHandler(
2049
+ instance: ElementNode,
2050
+ eventType: EventType,
2051
+ ): void {
2052
+ const existing = instance.events.get(eventType);
2053
+ if (!existing) {
2054
+ return;
2055
+ }
2056
+ unregisterHandler(existing.handlerId, instance.id, eventType, getRootIdForNode(instance));
2057
+ instance.events.delete(eventType);
2058
+ }
2059
+
2060
+ function bindFunctionPropEvent(
2061
+ instance: ElementNode,
2062
+ props: Props,
2063
+ activeEvents: Set<EventType>,
2064
+ binding: (typeof PRESS_EVENT_BINDINGS)[number],
2065
+ ): void {
2066
+ const handler = props[binding.propName];
2067
+ if (typeof handler === 'function') {
2068
+ activeEvents.add(binding.eventType);
2069
+ upsertEventHandler(instance, binding.eventType, handler);
2070
+ } else {
2071
+ removeEventHandler(instance, binding.eventType);
2072
+ }
2073
+ }
2074
+
2075
+ function eventPropsChanged(previousProps: Props, nextProps: Props): boolean {
2076
+ for (const propName of EVENT_PROP_NAMES) {
2077
+ if (previousProps[propName] !== nextProps[propName]) {
2078
+ return true;
2079
+ }
2080
+ }
2081
+ return false;
2082
+ }
2083
+
2084
+ /**
2085
+ * Shallow strict-equality check over original (framework-level) props.
2086
+ *
2087
+ * Used as the updateInstanceProps fast path: when every prop value (including
2088
+ * the `style` and `children` references) is identical, re-running
2089
+ * resolveElementState cannot produce a different result, so the whole
2090
+ * resolution + diff + encode pipeline can be skipped.
2091
+ */
2092
+ function originalPropsShallowEqual(previousProps: Props, nextProps: Props): boolean {
2093
+ const previousKeys = Object.keys(previousProps);
2094
+ if (previousKeys.length !== Object.keys(nextProps).length) {
2095
+ return false;
2096
+ }
2097
+ for (const key of previousKeys) {
2098
+ if (previousProps[key] !== nextProps[key]) {
2099
+ return false;
2100
+ }
2101
+ }
2102
+ return true;
2103
+ }
2104
+
2105
+ /**
2106
+ * Process event props for an element and update the handler registry.
2107
+ * This is framework-agnostic - each framework calls this with its props.
2108
+ *
2109
+ * IMPORTANT: This function updates handlers in place to maintain stable handler IDs.
2110
+ * The native side caches handler IDs from BindEvent opcodes, so we must not change
2111
+ * IDs unnecessarily.
2112
+ */
2113
+ export function processEventProps(
2114
+ instance: ElementNode,
2115
+ props: Props,
2116
+ config: TagConfig
2117
+ ): void {
2118
+ const supportsPressEvents =
2119
+ config.supportsPressEvents || instance.pressOverride === true;
2120
+ const supportsChangeEvents = config.supportsChangeEvents === true;
2121
+ const supportsTransitionEvents = config.supportsTransitionEvents !== false;
2122
+ const supportsScrollEvents = config.canonicalType === 'scroll';
2123
+
2124
+ // If this element doesn't support any events, clear all handlers
2125
+ if (!supportsPressEvents && !supportsChangeEvents && !supportsTransitionEvents && !supportsScrollEvents) {
2126
+ for (const [eventType, binding] of instance.events) {
2127
+ unregisterHandler(binding.handlerId, instance.id, eventType, getRootIdForNode(instance));
2128
+ }
2129
+ instance.events.clear();
2130
+ return;
2131
+ }
2132
+
2133
+ // Track which events are present in props
2134
+ const activeEvents = new Set<EventType>();
2135
+
2136
+ if (supportsPressEvents) {
2137
+ for (const binding of PRESS_EVENT_BINDINGS) {
2138
+ bindFunctionPropEvent(instance, props, activeEvents, binding);
2139
+ }
2140
+ }
2141
+
2142
+ // Process change events (for toggles, inputs)
2143
+ if (supportsChangeEvents) {
2144
+ if (instance.tagType === 'image') {
2145
+ const hasImageEventHandler =
2146
+ typeof props.onLoadStart === 'function' ||
2147
+ typeof props.onLoad === 'function' ||
2148
+ typeof props.onError === 'function' ||
2149
+ typeof props.onDisplay === 'function';
2150
+
2151
+ if (hasImageEventHandler) {
2152
+ activeEvents.add(EventType.Change);
2153
+ upsertEventHandler(instance, EventType.Change, (payload: unknown) => {
2154
+ dispatchImageEventPayload(props, payload);
2155
+ });
2156
+ } else {
2157
+ removeEventHandler(instance, EventType.Change);
2158
+ }
2159
+ } else if (instance.tagType === 'nativeView') {
2160
+ const moduleName = instance.props.nativeViewModuleName;
2161
+ const isToggleNativeView = moduleName === 'exact.toggle';
2162
+ const hasNativeViewChangeHandler =
2163
+ typeof props.onChange === 'function' ||
2164
+ (isToggleNativeView && typeof props.onValueChange === 'function');
2165
+
2166
+ if (hasNativeViewChangeHandler) {
2167
+ activeEvents.add(EventType.Change);
2168
+
2169
+ // Native-view controls report already-structured payloads from the host
2170
+ // (for example `{ value: 0.42 }` for sliders). Unlike text inputs, this
2171
+ // payload should not be coerced into a text event shape.
2172
+ const changeHandler = (payload: unknown) => {
2173
+ if (isToggleNativeView && typeof props.onValueChange === 'function') {
2174
+ const value =
2175
+ (payload as { nativeEvent?: { value?: unknown }; value?: unknown } | null)?.nativeEvent?.value ??
2176
+ (payload as { nativeEvent?: { value?: unknown }; value?: unknown } | null)?.value ??
2177
+ payload;
2178
+ props.onValueChange(value as boolean);
2179
+ }
2180
+
2181
+ const onChange = props.onChange as ((event: unknown) => void) | undefined;
2182
+ onChange?.(payload);
2183
+ };
2184
+
2185
+ upsertEventHandler(instance, EventType.Change, changeHandler);
2186
+
2187
+ if (typeof instance.props.nativeViewModuleName === 'string') {
2188
+ registerNativeViewEventHandler(
2189
+ instance.id,
2190
+ instance.props.nativeViewModuleName,
2191
+ (eventName, payload) => {
2192
+ if (eventName !== 'change') {
2193
+ return false;
2194
+ }
2195
+ return dispatchSyntheticEvent(instance, EventType.Change, payload);
2196
+ },
2197
+ );
2198
+ }
2199
+ } else {
2200
+ unregisterNativeViewEventHandler(instance.id);
2201
+ removeEventHandler(instance, EventType.Change);
2202
+ }
2203
+ } else {
2204
+ const hasToggleChangeHandler = typeof props.onValueChange === 'function';
2205
+ const hasTextChangeHandler =
2206
+ typeof props.onChangeText === 'function' ||
2207
+ typeof props.onChange === 'function' ||
2208
+ typeof props.onSelectionChange === 'function';
2209
+
2210
+ if (hasToggleChangeHandler || hasTextChangeHandler) {
2211
+ activeEvents.add(EventType.Change);
2212
+
2213
+ const changeHandler = (payload: unknown) => {
2214
+ if (typeof props.onValueChange === 'function') {
2215
+ const value =
2216
+ (payload as { nativeEvent?: { value?: unknown }; value?: unknown } | null)?.nativeEvent?.value ??
2217
+ (payload as { nativeEvent?: { value?: unknown }; value?: unknown } | null)?.value ??
2218
+ payload;
2219
+ props.onValueChange(value as boolean);
2220
+ }
2221
+
2222
+ if (typeof props.onChangeText === 'function' || typeof props.onChange === 'function') {
2223
+ const event = normalizeTextChangePayload(payload);
2224
+ const shouldDispatchCommittedText =
2225
+ event.nativeEvent.textChanged !== false &&
2226
+ event.nativeEvent.isComposing !== true;
2227
+ if (shouldDispatchCommittedText && typeof props.onChangeText === 'function') {
2228
+ props.onChangeText(event.nativeEvent.text);
2229
+ }
2230
+ if (shouldDispatchCommittedText && typeof props.onChange === 'function') {
2231
+ props.onChange(event);
2232
+ }
2233
+ }
2234
+
2235
+ if (typeof props.onSelectionChange === 'function') {
2236
+ const event = normalizeTextChangePayload(payload);
2237
+ if (event.nativeEvent.selection) {
2238
+ props.onSelectionChange({
2239
+ nativeEvent: {
2240
+ selection: event.nativeEvent.selection,
2241
+ text: event.nativeEvent.text,
2242
+ ...(event.nativeEvent.isComposing !== undefined
2243
+ ? { isComposing: event.nativeEvent.isComposing }
2244
+ : {}),
2245
+ ...(event.nativeEvent.compositionRange
2246
+ ? { compositionRange: event.nativeEvent.compositionRange }
2247
+ : {}),
2248
+ ...(event.nativeEvent.textRevision !== undefined
2249
+ ? { textRevision: event.nativeEvent.textRevision }
2250
+ : {}),
2251
+ ...(event.nativeEvent.classification !== undefined
2252
+ ? { classification: event.nativeEvent.classification }
2253
+ : {}),
2254
+ },
2255
+ });
2256
+ }
2257
+ }
2258
+ };
2259
+
2260
+ upsertEventHandler(instance, EventType.Change, changeHandler);
2261
+ } else {
2262
+ // Remove change handler if no longer in props
2263
+ removeEventHandler(instance, EventType.Change);
2264
+ }
2265
+ }
2266
+ }
2267
+
2268
+ if (supportsTransitionEvents) {
2269
+ if (typeof props.onTransitionEnd === 'function') {
2270
+ activeEvents.add(EventType.TransitionEnd);
2271
+ upsertEventHandler(instance, EventType.TransitionEnd, props.onTransitionEnd);
2272
+ } else {
2273
+ removeEventHandler(instance, EventType.TransitionEnd);
2274
+ }
2275
+ }
2276
+
2277
+ if (supportsScrollEvents) {
2278
+ if (typeof props.onScroll === 'function') {
2279
+ activeEvents.add(EventType.Scroll);
2280
+ const onScroll = props.onScroll as (event: ReturnType<typeof normalizeScrollPayload>) => void;
2281
+ const scrollEventThrottle = resolveScrollEventThrottle(props);
2282
+ const throttleState = getScrollThrottleState(instance);
2283
+ upsertEventHandler(instance, EventType.Scroll, (payload: unknown) => {
2284
+ const event = normalizeScrollPayload(payload);
2285
+ const timestamp = event.nativeEvent.timestamp;
2286
+ if (
2287
+ scrollEventThrottle > 0 &&
2288
+ throttleState.lastDispatchAt !== null &&
2289
+ timestamp >= throttleState.lastDispatchAt &&
2290
+ timestamp - throttleState.lastDispatchAt < scrollEventThrottle
2291
+ ) {
2292
+ return;
2293
+ }
2294
+ throttleState.lastDispatchAt = timestamp;
2295
+ onScroll(event);
2296
+ });
2297
+ } else {
2298
+ removeEventHandler(instance, EventType.Scroll);
2299
+ scrollThrottleState.delete(instance);
2300
+ }
2301
+ }
2302
+
2303
+ for (const binding of FOCUS_AND_KEYBOARD_BINDINGS) {
2304
+ const handler = props[binding.propName];
2305
+ const submitEditingHandler =
2306
+ instance.tagType === 'input' && binding.eventType === EventType.KeyDown
2307
+ ? props.onSubmitEditing
2308
+ : undefined;
2309
+ const needsInternalInputTracking =
2310
+ instance.tagType === 'input' &&
2311
+ (binding.eventType === EventType.Focus || binding.eventType === EventType.Blur);
2312
+
2313
+ if (
2314
+ typeof handler === 'function' ||
2315
+ typeof submitEditingHandler === 'function' ||
2316
+ needsInternalInputTracking
2317
+ ) {
2318
+ activeEvents.add(binding.eventType);
2319
+ upsertEventHandler(instance, binding.eventType, (payload: unknown) => {
2320
+ const normalizedPayload = binding.normalize(payload);
2321
+ if (binding.eventType === EventType.Focus) {
2322
+ syncInputFocusTracking(instance, true);
2323
+ } else if (binding.eventType === EventType.Blur) {
2324
+ syncInputFocusTracking(instance, false);
2325
+ }
2326
+ if (typeof handler === 'function') {
2327
+ handler(normalizedPayload);
2328
+ }
2329
+ if (
2330
+ typeof submitEditingHandler === 'function' &&
2331
+ binding.eventType === EventType.KeyDown &&
2332
+ (normalizedPayload as { nativeEvent: { key?: string } }).nativeEvent.key === 'Enter' &&
2333
+ props.multiline !== true
2334
+ ) {
2335
+ submitEditingHandler();
2336
+ }
2337
+ });
2338
+ } else {
2339
+ removeEventHandler(instance, binding.eventType);
2340
+ }
2341
+ }
2342
+
2343
+ // Remove handlers for events no longer in props
2344
+ for (const [eventType, binding] of instance.events) {
2345
+ if (!activeEvents.has(eventType)) {
2346
+ unregisterHandler(binding.handlerId, instance.id, eventType, getRootIdForNode(instance));
2347
+ instance.events.delete(eventType);
2348
+ }
2349
+ }
2350
+ }
2351
+
2352
+ /**
2353
+ * Recursively clean up event handlers for a node and all its children.
2354
+ * This ensures native event listeners are properly released.
2355
+ */
2356
+ export function cleanupNodeHandlers(
2357
+ node: ElementNode | TextNode,
2358
+ rootId: number = getRootIdForNode(node),
2359
+ ): void {
2360
+ if (node.kind === NodeKind.Element) {
2361
+ const element = node as ElementNode;
2362
+
2363
+ if (element.tagType === 'nativeView') {
2364
+ unregisterNativeViewEventHandler(element.id);
2365
+ }
2366
+
2367
+ // Unregister all event handlers for this node
2368
+ for (const [eventType, binding] of element.events) {
2369
+ unregisterHandler(binding.handlerId, element.id, eventType, rootId);
2370
+ }
2371
+ element.events.clear();
2372
+
2373
+ // Recursively clean up children
2374
+ for (const child of element.children) {
2375
+ cleanupNodeHandlers(child, rootId);
2376
+ }
2377
+ }
2378
+ }
2379
+
2380
+ // =============================================================================
2381
+ // High-Level Host Operations
2382
+ // =============================================================================
2383
+
2384
+ /**
2385
+ * Create an element instance.
2386
+ * Used by framework adapters during React's render phase, so this must remain side-effect free.
2387
+ */
2388
+ export function createInstance(
2389
+ type: string,
2390
+ props: Props,
2391
+ ): ElementNode {
2392
+ const config = getTagConfig(type) ?? defaultTagConfig;
2393
+
2394
+ // Create the element node
2395
+ const instance = createElementNode(
2396
+ config.canonicalType,
2397
+ type,
2398
+ config.isRNStyle,
2399
+ props
2400
+ );
2401
+
2402
+ applyResolvedElementState(
2403
+ instance,
2404
+ resolveElementState(instance, config, type, props, getRootInheritedState(0)),
2405
+ );
2406
+
2407
+ // Event handlers are intentionally NOT registered here: React can abort a
2408
+ // render and drop the instance, which would leak entries in the global
2409
+ // handler registry. Registration happens at commit, in encodeCreatedSubtree.
2410
+ return instance;
2411
+ }
2412
+
2413
+ /**
2414
+ * Create a text instance.
2415
+ * Used by framework adapters during React's render phase, so this must remain side-effect free.
2416
+ */
2417
+ export function createTextInstance(text: string): TextNode {
2418
+ return createTextNode(text);
2419
+ }
2420
+
2421
+ function encodeCreatedSubtree(
2422
+ encoder: ProtocolEncoder,
2423
+ node: ElementNode | TextNode,
2424
+ inheritedState: InheritedElementState = getRootInheritedState(),
2425
+ inheritedTextMeasureStyle: CanonicalStyle | null = null,
2426
+ ): void {
2427
+ if (!hasDirtyFlag(node, DirtyFlags.Created)) {
2428
+ return;
2429
+ }
2430
+
2431
+ if (node.kind === NodeKind.Text) {
2432
+ encodeCreateText(encoder, node);
2433
+ if (inheritedTextMeasureStyle) {
2434
+ setStyle(encoder, node.id, inheritedTextMeasureStyle);
2435
+ }
2436
+ markViewChanged(node);
2437
+ clearDirtyFlags(node);
2438
+ return;
2439
+ }
2440
+
2441
+ const nodeTextMeasureStyle = inheritedTextMeasureStyleForChild(
2442
+ node,
2443
+ inheritedTextMeasureStyle,
2444
+ );
2445
+
2446
+ const config = getTagConfig(node.originalTag) ?? defaultTagConfig;
2447
+ // Commit-time event registration (createInstance is render-phase pure).
2448
+ // processEventProps is idempotent, so adapters that already registered
2449
+ // handlers imperatively (DOM shim, Solid, Vue) keep their stable handler IDs.
2450
+ processEventProps(node, node.originalProps, config);
2451
+ if (canRefreshCreatedMountEnvironment(node, inheritedState)) {
2452
+ refreshCreatedMountEnvironment(node, inheritedState);
2453
+ } else {
2454
+ applyResolvedElementState(
2455
+ node,
2456
+ resolveElementState(node, config, node.originalTag, node.originalProps, inheritedState),
2457
+ );
2458
+ }
2459
+ encodeCreateElement(encoder, node);
2460
+ if (inheritedTextMeasureStyle && node.tagType === 'text' && nodeTextMeasureStyle) {
2461
+ setStyle(encoder, node.id, nodeTextMeasureStyle);
2462
+ }
2463
+ markViewChanged(node);
2464
+ const childInheritedState: InheritedElementState = {
2465
+ rootId: node.propagatedSafeArea?.rootId ?? inheritedState.rootId,
2466
+ direction: node.resolvedDirection,
2467
+ writingMode: node.resolvedWritingMode,
2468
+ lang: node.resolvedLang,
2469
+ safeArea: node.propagatedSafeArea ?? inheritedState.safeArea,
2470
+ };
2471
+ for (const child of node.children) {
2472
+ encodeCreatedSubtree(encoder, child, childInheritedState, nodeTextMeasureStyle);
2473
+ }
2474
+ if (node.children.length > 0) {
2475
+ encodeChildren(encoder, node);
2476
+ }
2477
+ clearDirtyFlags(node);
2478
+ }
2479
+
2480
+ function syncInheritedStateInSubtree(
2481
+ encoder: ProtocolEncoder,
2482
+ node: ElementNode | TextNode,
2483
+ ): void {
2484
+ if (node.kind === NodeKind.Text) {
2485
+ return;
2486
+ }
2487
+
2488
+ const config = getTagConfig(node.originalTag) ?? defaultTagConfig;
2489
+ const previousStyle = node.style;
2490
+ const previousProps = node.props;
2491
+ const previousTransitions = node.transitions;
2492
+ const previousDirection = node.resolvedDirection;
2493
+ const previousWritingMode = node.resolvedWritingMode;
2494
+ const previousLang = node.resolvedLang;
2495
+ const previousSafeArea = node.propagatedSafeArea;
2496
+ const nextState = resolveElementState(
2497
+ node,
2498
+ config,
2499
+ node.originalTag,
2500
+ node.originalProps,
2501
+ getInheritedState(node.parent as ElementNode | RootNode | null),
2502
+ );
2503
+ const changedStyleKeys = collectChangedStyleKeys(previousStyle, nextState.style);
2504
+ const styleChanged = changedStyleKeys.length > 0;
2505
+ const propsChanged = !propsEqual(previousProps, nextState.props);
2506
+ const transitionsChanged = !transitionsEqual(previousTransitions, nextState.transitions);
2507
+ const inheritedStateChanged =
2508
+ previousDirection !== nextState.resolvedDirection ||
2509
+ previousWritingMode !== nextState.resolvedWritingMode ||
2510
+ previousLang !== nextState.resolvedLang ||
2511
+ !safeAreaPropagationEqual(previousSafeArea, nextState.propagatedSafeArea);
2512
+
2513
+ applyResolvedElementState(node, nextState);
2514
+
2515
+ if (!hasDirtyFlag(node, DirtyFlags.Created)) {
2516
+ if (styleChanged) {
2517
+ markViewChanged(node);
2518
+ if (styleKeysAffectLayout(changedStyleKeys)) {
2519
+ markLayoutDirtyForNode(node);
2520
+ }
2521
+ encodeStyleUpdate(encoder, node.id, changedStyleKeys, node.style);
2522
+ if (node.tagType === 'text' && styleKeysAffectTextLeafMeasure(changedStyleKeys)) {
2523
+ encodeTextLeafMeasureStyles(encoder, node);
2524
+ }
2525
+ }
2526
+
2527
+ if (transitionsChanged) {
2528
+ markViewChanged(node);
2529
+ setTransition(encoder, node.id, node.transitions);
2530
+ }
2531
+
2532
+ if (propsChanged) {
2533
+ markViewChanged(node);
2534
+ getPendingCommitState(getRootIdForNode(node)).warningAnalysisDirty = true;
2535
+ encodeProps(encoder, node);
2536
+ } else if (
2537
+ previousLang !== node.resolvedLang &&
2538
+ typeof node.props.lang === 'string' &&
2539
+ node.props.lang.length > 0
2540
+ ) {
2541
+ markViewChanged(node);
2542
+ setLang(encoder, node.id, node.resolvedLang);
2543
+ }
2544
+ }
2545
+
2546
+ if (!inheritedStateChanged) {
2547
+ return;
2548
+ }
2549
+
2550
+ for (const child of node.children) {
2551
+ syncInheritedStateInSubtree(encoder, child);
2552
+ }
2553
+ }
2554
+
2555
+ function syncSelectablePropsInSubtree(
2556
+ encoder: ProtocolEncoder,
2557
+ node: ElementNode | TextNode,
2558
+ ): void {
2559
+ if (node.kind === NodeKind.Text) {
2560
+ return;
2561
+ }
2562
+
2563
+ if (
2564
+ !hasDirtyFlag(node, DirtyFlags.Created) &&
2565
+ (node.tagType === 'text' || node.props.selectable !== undefined)
2566
+ ) {
2567
+ encodeProps(encoder, node);
2568
+ }
2569
+
2570
+ for (const child of node.children) {
2571
+ syncSelectablePropsInSubtree(encoder, child);
2572
+ }
2573
+ }
2574
+
2575
+ function hasCommittedViewportSizeChanged(rootId: number): boolean {
2576
+ const currentViewport = getScreenDimensions(rootId);
2577
+ const previousViewport = committedRootViewportSizes.get(rootId);
2578
+ return (
2579
+ previousViewport === undefined ||
2580
+ previousViewport.width !== currentViewport.width ||
2581
+ previousViewport.height !== currentViewport.height
2582
+ );
2583
+ }
2584
+
2585
+ function syncRootInheritedState(root: RootNode): void {
2586
+ const enc = getEncoderForRoot(root.rootId);
2587
+ const opCountBefore = enc.getOpCount();
2588
+
2589
+ for (const child of root.children) {
2590
+ syncInheritedStateInSubtree(enc, child);
2591
+ }
2592
+
2593
+ if (enc.getOpCount() > opCountBefore) {
2594
+ commitBatch(root);
2595
+ }
2596
+ }
2597
+
2598
+ function syncLiveRootInheritedState(): void {
2599
+ for (const root of liveRoots.values()) {
2600
+ syncRootInheritedState(root);
2601
+ }
2602
+ }
2603
+
2604
+ /**
2605
+ * Re-resolve inherited layout state for an already-mounted root after the host
2606
+ * environment changes.
2607
+ *
2608
+ * Window state changes (viewport size, safe area insets, keyboard metrics)
2609
+ * affect style resolution even when JSX/template props stay byte-for-byte
2610
+ * identical. Adapters call this helper from their root window subscriptions so
2611
+ * host-ops can recompute the affected styles without relying on framework prop
2612
+ * diffs.
2613
+ */
2614
+ export function syncRootWindowState(root: RootNode): void {
2615
+ const enc = getEncoderForRoot(root.rootId);
2616
+
2617
+ for (const child of root.children) {
2618
+ syncInheritedStateInSubtree(enc, child);
2619
+ }
2620
+
2621
+ commitBatch(root);
2622
+ }
2623
+
2624
+ export function syncRootInheritedWindowState(root: RootNode): void {
2625
+ const enc = getEncoderForRoot(root.rootId);
2626
+ const opCountBefore = enc.getOpCount();
2627
+ const viewportChanged = hasCommittedViewportSizeChanged(root.rootId);
2628
+
2629
+ for (const child of root.children) {
2630
+ syncInheritedStateInSubtree(enc, child);
2631
+ }
2632
+
2633
+ if (enc.getOpCount() > opCountBefore) {
2634
+ commitBatch(root);
2635
+ return;
2636
+ }
2637
+
2638
+ if (viewportChanged) {
2639
+ committedRootViewportSizes.set(root.rootId, getScreenDimensions(root.rootId));
2640
+ }
2641
+ }
2642
+
2643
+ /**
2644
+ * Append a child to a parent and encode the operation.
2645
+ */
2646
+ export function appendChild(parent: ElementNode | RootNode, child: ElementNode | TextNode): void {
2647
+ nodeAppendChild(parent, child as ElementNode);
2648
+ const enc = getEncoderForNode(parent);
2649
+ // Selectable re-sync must run while Created flags are still intact: its
2650
+ // !Created gate skips nodes that encodeCreatedSubtree is about to encode in
2651
+ // full, so only pre-existing (reparented) nodes get their props re-emitted.
2652
+ syncSelectablePropsInSubtree(enc, child);
2653
+ encodeCreatedSubtree(
2654
+ enc,
2655
+ child,
2656
+ getInheritedState(parent),
2657
+ inheritedTextMeasureStyleForInsertedChildren(parent),
2658
+ );
2659
+ markChildrenDirty(parent);
2660
+ }
2661
+
2662
+ /**
2663
+ * Insert a child before another child and encode the operation.
2664
+ */
2665
+ export function insertBefore(
2666
+ parent: ElementNode | RootNode,
2667
+ child: ElementNode | TextNode,
2668
+ beforeChild: ElementNode | TextNode
2669
+ ): void {
2670
+ nodeInsertBefore(parent, child as ElementNode, beforeChild as ElementNode);
2671
+ const enc = getEncoderForNode(parent);
2672
+ // See appendChild: run before encoding so the !Created gate is meaningful.
2673
+ syncSelectablePropsInSubtree(enc, child);
2674
+ encodeCreatedSubtree(
2675
+ enc,
2676
+ child,
2677
+ getInheritedState(parent),
2678
+ inheritedTextMeasureStyleForInsertedChildren(parent),
2679
+ );
2680
+ markChildrenDirty(parent);
2681
+ }
2682
+
2683
+ /**
2684
+ * Detach a child from its parent without destroying the subtree.
2685
+ *
2686
+ * This is the DOM-spec-style "remove from this parent, but keep the node alive"
2687
+ * primitive required for RFC 0043 reparenting. Event handlers stay registered
2688
+ * and descendants keep their identity so the subtree can be inserted again.
2689
+ */
2690
+ export function detachChild(parent: ElementNode | RootNode, child: ElementNode | TextNode): void {
2691
+ nodeRemoveChild(parent, child as ElementNode);
2692
+ markChildrenDirty(parent);
2693
+ }
2694
+
2695
+ /**
2696
+ * Remove a child from a parent and destroy the detached subtree.
2697
+ *
2698
+ * The destructive behavior here is deliberate: existing React/Solid/Vue host
2699
+ * configs use removeChild for final removal. RFC 0043 adds detachChild for the
2700
+ * non-destructive DOM-shim path.
2701
+ */
2702
+ export function removeChild(parent: ElementNode | RootNode, child: ElementNode | TextNode): void {
2703
+ const enc = getEncoderForNode(parent);
2704
+ const rootId = getRootIdForNode(parent);
2705
+ // Clean up event handlers
2706
+ cleanupNodeHandlers(child, rootId);
2707
+ nodeRemoveChild(parent, child as ElementNode);
2708
+ if (!hasDirtyFlag(child, DirtyFlags.Created)) {
2709
+ // Pass rootId explicitly: the child is already severed from the tree, so
2710
+ // walking up from it would misattribute the destroyed views to root 0.
2711
+ destroySubtree(child, enc, rootId);
2712
+ }
2713
+ markChildrenDirty(parent);
2714
+ }
2715
+
2716
+ /**
2717
+ * Clear all children from a container and encode the operation.
2718
+ */
2719
+ export function clearContainer(container: RootNode): void {
2720
+ const enc = getEncoderForNode(container);
2721
+ const rootId = container.rootId;
2722
+ // Clean up all event handlers and destroy children
2723
+ for (const child of container.children.slice()) {
2724
+ cleanupNodeHandlers(child, rootId);
2725
+ if (!hasDirtyFlag(child, DirtyFlags.Created)) {
2726
+ destroySubtree(child, enc, rootId);
2727
+ }
2728
+ }
2729
+
2730
+ nodeClearChildren(container);
2731
+ markChildrenDirty(container);
2732
+ }
2733
+
2734
+ /**
2735
+ * Destroy a root and flush the teardown batch before releasing its encoder.
2736
+ *
2737
+ * This is intentionally per-root instead of global reset logic so DOM handles,
2738
+ * multi-root adapters, and HMR teardown can remove one root without affecting
2739
+ * any others.
2740
+ */
2741
+ export function destroyRoot(rootId: number): void {
2742
+ const root = liveRoots.get(rootId);
2743
+ const enc = encoders.get(rootId);
2744
+
2745
+ if (!root) {
2746
+ if (enc) {
2747
+ dispatchProtocol(enc);
2748
+ encoders.delete(rootId);
2749
+ }
2750
+ unregisterInspectorRoot(rootId);
2751
+ rootOwners.delete(rootId);
2752
+ return;
2753
+ }
2754
+
2755
+ clearContainer(root);
2756
+ const encoder = getEncoderForRoot(rootId);
2757
+ flushDirtyChildren(encoder, pendingCommitStateByRoot.get(rootId));
2758
+ dispatchProtocol(encoder);
2759
+ encoders.delete(rootId);
2760
+ pendingCommitStateByRoot.delete(rootId);
2761
+ unregisterInspectorRoot(rootId);
2762
+ rootOwners.delete(rootId);
2763
+ (globalThis as typeof globalThis & {
2764
+ __exactDomMirror?: { remove(rootId: number): void };
2765
+ }).__exactDomMirror?.remove(rootId);
2766
+ }
2767
+
2768
+ /**
2769
+ * Recursively destroy a node and all of its descendants.
2770
+ *
2771
+ * Important: we must encode destroys bottom-up so parents don't temporarily
2772
+ * reference children that no longer exist during intermediate batches.
2773
+ */
2774
+ function destroySubtree(
2775
+ node: ElementNode | TextNode,
2776
+ enc?: ProtocolEncoder,
2777
+ rootId: number = getRootIdForNode(node),
2778
+ ): void {
2779
+ const encoder = enc ?? getEncoderForRoot(rootId);
2780
+ if (node.kind === NodeKind.Element) {
2781
+ // Copy because children array is still used by node ops.
2782
+ const children = (node as ElementNode).children.slice();
2783
+ for (const child of children) {
2784
+ destroySubtree(child, encoder, rootId);
2785
+ }
2786
+ }
2787
+
2788
+ // The owning rootId is threaded through (like cleanupNodeHandlers) because
2789
+ // the subtree may already be detached, which would resolve to root 0.
2790
+ getPendingCommitState(rootId).changedViews.add(node.id);
2791
+ encodeDestroy(encoder, node);
2792
+ }
2793
+
2794
+ /**
2795
+ * Update an element's props and encode changes.
2796
+ */
2797
+ export function updateInstanceProps(
2798
+ instance: ElementNode,
2799
+ oldProps: Props,
2800
+ newProps: Props
2801
+ ): void {
2802
+ const config = getTagConfig(instance.originalTag) ?? defaultTagConfig;
2803
+
2804
+ // Update style if changed
2805
+ // NOTE: React 19 may pass the same props object for both oldProps and newProps,
2806
+ // so we compare against the stored instance.style instead of oldProps.style
2807
+ const previousProps = instance.originalProps ?? oldProps;
2808
+
2809
+ // Fast path: React 19 commits a host update whenever the props object
2810
+ // identity changes, even if every prop value is unchanged (a parent
2811
+ // re-render recreates the element). Shallow-equal props (style and children
2812
+ // compared by identity) cannot change the resolved state, so skip the full
2813
+ // resolveElementState pipeline. The same-reference case must NOT take this
2814
+ // path: the DOM shim mutates its live props object in place and relies on
2815
+ // full re-resolution to discover changes.
2816
+ if (previousProps !== newProps && originalPropsShallowEqual(previousProps, newProps)) {
2817
+ instance.originalProps = newProps;
2818
+ return;
2819
+ }
2820
+
2821
+ const oldCanonicalProps = instance.props;
2822
+ const oldStyle = instance.style;
2823
+ const oldResolvedDirection = instance.resolvedDirection;
2824
+ const oldResolvedWritingMode = instance.resolvedWritingMode;
2825
+ const oldResolvedLang = instance.resolvedLang;
2826
+ const oldPropagatedSafeArea = instance.propagatedSafeArea;
2827
+ const oldTransitions = instance.transitions;
2828
+ const oldParagraphKey = instance.paragraphKey;
2829
+ const nextState = resolveElementState(
2830
+ instance,
2831
+ config,
2832
+ instance.originalTag,
2833
+ newProps,
2834
+ getInheritedState(instance.parent as ElementNode | RootNode | null),
2835
+ );
2836
+ const newCanonicalProps = nextState.props;
2837
+ const newStyle = nextState.style;
2838
+ const newTransitions = nextState.transitions;
2839
+ const changedStyleKeys = collectChangedStyleKeys(oldStyle, newStyle);
2840
+ const styleChanged = changedStyleKeys.length > 0;
2841
+ const transitionsChanged = !transitionsEqual(oldTransitions, newTransitions);
2842
+ const propsChanged = !propsEqual(oldCanonicalProps, newCanonicalProps);
2843
+ const textContentChanged =
2844
+ oldCanonicalProps.textContent !== newCanonicalProps.textContent ||
2845
+ oldCanonicalProps.value !== newCanonicalProps.value;
2846
+ const textMeasureChanged =
2847
+ instance.tagType === 'text' && oldParagraphKey !== nextState.paragraphKey;
2848
+ const inheritedStateChanged =
2849
+ oldResolvedDirection !== nextState.resolvedDirection ||
2850
+ oldResolvedWritingMode !== nextState.resolvedWritingMode ||
2851
+ oldResolvedLang !== nextState.resolvedLang ||
2852
+ !safeAreaPropagationEqual(oldPropagatedSafeArea, nextState.propagatedSafeArea);
2853
+
2854
+ applyResolvedElementState(instance, nextState);
2855
+
2856
+ // Update events if changed
2857
+ const eventsChanged = eventPropsChanged(previousProps, newProps);
2858
+
2859
+ if (eventsChanged) {
2860
+ processEventProps(instance, newProps, config);
2861
+ }
2862
+
2863
+ // Store the new original props for future diffing
2864
+ instance.originalProps = newProps;
2865
+
2866
+ if (hasDirtyFlag(instance, DirtyFlags.Created)) {
2867
+ return;
2868
+ }
2869
+
2870
+ const enc = getEncoderForNode(instance);
2871
+
2872
+ if (styleChanged) {
2873
+ markViewChanged(instance);
2874
+ if (styleKeysAffectLayout(changedStyleKeys)) {
2875
+ markLayoutDirtyForNode(instance);
2876
+ }
2877
+ encodeStyleUpdate(enc, instance.id, changedStyleKeys, instance.style);
2878
+ if (instance.tagType === 'text' && styleKeysAffectTextLeafMeasure(changedStyleKeys)) {
2879
+ encodeTextLeafMeasureStyles(enc, instance);
2880
+ }
2881
+ }
2882
+
2883
+ if (transitionsChanged) {
2884
+ markViewChanged(instance);
2885
+ setTransition(enc, instance.id, instance.transitions);
2886
+ }
2887
+
2888
+ if (propsChanged) {
2889
+ markViewChanged(instance);
2890
+ getPendingCommitState(getRootIdForNode(instance)).warningAnalysisDirty = true;
2891
+ const canPatchStableTextContent =
2892
+ textContentChanged &&
2893
+ !styleChanged &&
2894
+ canPatchElementTextWithoutLayout(instance, newProps);
2895
+ if (
2896
+ (textMeasureChanged || textContentChanged) &&
2897
+ !canPatchStableTextContent
2898
+ ) {
2899
+ markLayoutDirtyForNode(instance);
2900
+ }
2901
+ if (canPatchStableTextContent && typeof newCanonicalProps.textContent === 'string') {
2902
+ setTextContent(enc, instance.id, newCanonicalProps.textContent);
2903
+ } else {
2904
+ encodeProps(enc, instance);
2905
+ }
2906
+ } else if (
2907
+ oldResolvedLang !== instance.resolvedLang &&
2908
+ typeof instance.props.lang === 'string' &&
2909
+ instance.props.lang.length > 0
2910
+ ) {
2911
+ markViewChanged(instance);
2912
+ setLang(enc, instance.id, instance.resolvedLang);
2913
+ }
2914
+
2915
+ if (oldCanonicalProps.selectable !== newCanonicalProps.selectable) {
2916
+ for (const child of instance.children) {
2917
+ syncSelectablePropsInSubtree(enc, child);
2918
+ }
2919
+ }
2920
+
2921
+ if (inheritedStateChanged) {
2922
+ for (const child of instance.children) {
2923
+ syncInheritedStateInSubtree(enc, child);
2924
+ }
2925
+ }
2926
+
2927
+ if (eventsChanged) {
2928
+ markViewChanged(instance);
2929
+ getPendingCommitState(getRootIdForNode(instance)).warningAnalysisDirty = true;
2930
+ encodeEvents(enc, instance);
2931
+ }
2932
+ }
2933
+
2934
+ /**
2935
+ * Update a text node's content and encode the change.
2936
+ */
2937
+ export function updateTextContent(instance: TextNode, newText: string): void {
2938
+ instance.text = newText;
2939
+ if (hasDirtyFlag(instance, DirtyFlags.Created)) {
2940
+ return;
2941
+ }
2942
+ markViewChanged(instance);
2943
+ if (!canPatchTextNodeWithoutLayout(instance)) {
2944
+ markLayoutDirtyForNode(instance);
2945
+ }
2946
+ setTextContent(getEncoderForNode(instance), instance.id, newText);
2947
+ }
2948
+
2949
+ /**
2950
+ * Finalize initial children after tree building.
2951
+ * Kept as a no-op because protocol encoding happens only once React commits placement.
2952
+ */
2953
+ export function finalizeInstance(instance: ElementNode): void {
2954
+ void instance;
2955
+ }
2956
+
2957
+ /**
2958
+ * Commit the batch - set root styles, compute layout, and dispatch to native.
2959
+ */
2960
+ export function commitBatch(container: RootNode): void {
2961
+ const enc = getEncoderForRoot(container.rootId);
2962
+ const state = getPendingCommitState(container.rootId);
2963
+ const rootChildrenDirty =
2964
+ hasDirtyFlag(container, DirtyFlags.Created) ||
2965
+ hasDirtyFlag(container, DirtyFlags.Children);
2966
+ if (rootChildrenDirty) {
2967
+ state.dirtyChildren.add(container);
2968
+ state.layoutDirty = true;
2969
+ }
2970
+
2971
+ flushDirtyChildren(enc, state);
2972
+
2973
+ const { width, height } = getScreenDimensions(container.rootId);
2974
+ const previousViewport = committedRootViewportSizes.get(container.rootId);
2975
+ const viewportChanged =
2976
+ previousViewport === undefined ||
2977
+ previousViewport.width !== width ||
2978
+ previousViewport.height !== height;
2979
+
2980
+ if (viewportChanged) {
2981
+ committedRootViewportSizes.set(container.rootId, { width, height });
2982
+ state.layoutDirty = true;
2983
+ state.changedViews.add(container.id);
2984
+
2985
+ setStyle(enc, container.id, {
2986
+ width: { type: 'points', value: width },
2987
+ height: { type: 'points', value: height },
2988
+ flexDirection: 'column',
2989
+ alignItems: 'stretch',
2990
+ justifyContent: 'flex-start',
2991
+ });
2992
+ }
2993
+
2994
+ if (state.layoutDirty) {
2995
+ enc.computeLayout(container.id, width, height);
2996
+ }
2997
+
2998
+ if (enc.getOpCount() > 0) {
2999
+ dispatchProtocol(enc);
3000
+ }
3001
+ const changedViews = Array.from(state.changedViews);
3002
+ const analyzeWarnings = state.warningAnalysisDirty;
3003
+ state.layoutDirty = false;
3004
+ state.warningAnalysisDirty = false;
3005
+ state.changedViews.clear();
3006
+ clearDirtyFlags(container);
3007
+ notifyCommit(container, {
3008
+ changedViews,
3009
+ analyzeWarnings,
3010
+ });
3011
+ syncKeyboardAvoidanceAfterCommit(container.rootId);
3012
+ // Plain-web pixels for host-ops adapters: when the DOM mirror is installed
3013
+ // (no protocol host on the page), it re-renders this root's node tree into
3014
+ // real DOM after every commit. Undefined everywhere else.
3015
+ (globalThis as typeof globalThis & {
3016
+ __exactDomMirror?: { sync(root: RootNode): void };
3017
+ }).__exactDomMirror?.sync(container);
3018
+ }
3019
+
3020
+ /**
3021
+ * Render a minimal fallback message when framework rendering fails.
3022
+ * This keeps the native tree in a coherent state instead of leaving the app blank.
3023
+ */
3024
+ export function renderErrorFallback(container: RootNode, message: string): void {
3025
+ const enc = getEncoderForRoot(container.rootId);
3026
+ const fallbackProps: Props = {
3027
+ style: {
3028
+ color: '#ff3b30',
3029
+ padding: 16,
3030
+ fontSize: 14,
3031
+ },
3032
+ };
3033
+
3034
+ clearContainer(container);
3035
+
3036
+ const fallback = createInstance('Text', fallbackProps);
3037
+ encodeCreateElement(enc, fallback);
3038
+
3039
+ const text = createTextNode(message);
3040
+ encodeCreateText(enc, text);
3041
+
3042
+ nodeAppendChild(fallback, text);
3043
+ nodeAppendChild(container, fallback);
3044
+ encodeChildren(enc, fallback);
3045
+ encodeChildren(enc, container);
3046
+ commitBatch(container);
3047
+ }
3048
+
3049
+ /**
3050
+ * Create the root container and register it with native.
3051
+ *
3052
+ * @param rootId - Root ID for multi-root support (default 0 = single root)
3053
+ * @param owner - Optional public adapter owner name. When supplied, the root
3054
+ * ID is claimed so another adapter/DOM handle cannot silently reuse it.
3055
+ */
3056
+ export function createRoot(rootId: number = 0, owner?: string): RootNode {
3057
+ if (liveRoots.has(rootId)) {
3058
+ throw new Error(`Root ${rootId} is already initialized.`);
3059
+ }
3060
+
3061
+ if (owner) {
3062
+ claimRoot(rootId, owner);
3063
+ }
3064
+
3065
+ const root = createRootNode(rootId);
3066
+ const enc = getEncoderForRoot(rootId);
3067
+ enc.createView(root.id, NodeType.View);
3068
+ liveRoots.set(root.rootId, root);
3069
+ ensureLocaleRootSync();
3070
+ ensureAccessibilityRootSync();
3071
+ registerRoot(root);
3072
+
3073
+ const ensureAgentServer = (
3074
+ globalThis as {
3075
+ __exactEnsureAgentServer?: (() => void) | undefined;
3076
+ }
3077
+ ).__exactEnsureAgentServer;
3078
+ const shouldBootAgentServer =
3079
+ (globalThis as {
3080
+ __exactAgentBootstrap?: {
3081
+ boot?: boolean;
3082
+ };
3083
+ }).__exactAgentBootstrap?.boot === true ||
3084
+ (typeof process === 'object' &&
3085
+ process !== null &&
3086
+ typeof process.env?.EXACT_AGENT_BOOT === 'string' &&
3087
+ process.env.EXACT_AGENT_BOOT === '1');
3088
+
3089
+ if (shouldBootAgentServer && typeof ensureAgentServer === 'function') {
3090
+ ensureAgentServer();
3091
+ }
3092
+
3093
+ return root;
3094
+ }
3095
+
3096
+ export function unregisterInspectorRoot(rootId: number): void {
3097
+ liveRoots.delete(rootId);
3098
+ committedRootViewportSizes.delete(rootId);
3099
+ pendingCommitStateByRoot.delete(rootId);
3100
+ maybeDisposeLocaleRootSync();
3101
+ maybeDisposeAccessibilityRootSync();
3102
+ unregisterRoot(rootId);
3103
+ }
3104
+
3105
+ export function _resetHostOpsState(): void {
3106
+ liveRoots.clear();
3107
+ rootOwners.clear();
3108
+ committedRootViewportSizes.clear();
3109
+ pendingCommitStateByRoot.clear();
3110
+ maybeDisposeLocaleRootSync();
3111
+ maybeDisposeAccessibilityRootSync();
3112
+ encoders.clear();
3113
+ warnedAutoDirectionTags.clear();
3114
+ resetProtocolTapForTests();
3115
+ resetKeyboardAvoidanceState();
3116
+ clearFocusedTarget();
3117
+ }
3118
+
3119
+ // ---------------------------------------------------------------------------
3120
+ // LLP 0281 stage 0 — the host channel-application seam
3121
+ // ---------------------------------------------------------------------------
3122
+ //
3123
+ // A value-channel style sink (a `transform`/`opacity` attr driven by a
3124
+ // frame-coalesced channel value) must reach pixels as compositor-only work,
3125
+ // bypassing the reactive flush and the host-ops prop diff entirely. Hosts
3126
+ // that can do that register an applier here (the web dom-mirror writes the
3127
+ // style straight onto the DOM node); the Contract runtime calls
3128
+ // `applyHostChannelStyle` per coalesced frame and falls back to an ordinary
3129
+ // coalesced prop update when no applier is installed (correct semantics,
3130
+ // slower path — the native fast path arrives with LLP 0099's substrate).
3131
+
3132
+ export type HostChannelStyleApplier = (
3133
+ /** The host-ops element node the sink is bound to. */
3134
+ node: unknown,
3135
+ /** Lowered style prop name (`transform`, `opacity`). */
3136
+ styleProp: string,
3137
+ /** Evaluated sink value (string transform, numeric opacity, ...). */
3138
+ value: unknown,
3139
+ ) => boolean;
3140
+
3141
+ let hostChannelStyleApplier: HostChannelStyleApplier | null = null;
3142
+
3143
+ /** Install (or clear, with null) the host's channel style applier. */
3144
+ export function setHostChannelStyleApplier(next: HostChannelStyleApplier | null): void {
3145
+ hostChannelStyleApplier = next;
3146
+ }
3147
+
3148
+ export function getHostChannelStyleApplier(): HostChannelStyleApplier | null {
3149
+ return hostChannelStyleApplier;
3150
+ }
3151
+
3152
+ /**
3153
+ * Apply a channel-driven style value through the host fast path. Returns
3154
+ * false when no host applier is installed or the applier declined the node
3155
+ * — callers then fall back to the coalesced host-ops update.
3156
+ */
3157
+ export function applyHostChannelStyle(node: unknown, styleProp: string, value: unknown): boolean {
3158
+ if (!hostChannelStyleApplier) {
3159
+ return false;
3160
+ }
3161
+ try {
3162
+ return hostChannelStyleApplier(node, styleProp, value);
3163
+ } catch (error) {
3164
+ console.warn('[exact] host channel style applier failed', error);
3165
+ return false;
3166
+ }
3167
+ }