@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,2234 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { _clearHandlers } from '../host-config.js';
4
+ import {
5
+ _getResolveElementStateCallCount,
6
+ _resetResolveElementStateCallCount,
7
+ _resetHostOpsState,
8
+ _getEncoder,
9
+ commitBatch,
10
+ destroyRoot,
11
+ dispatchSyntheticEvent,
12
+ detachChild,
13
+ EventType,
14
+ appendChild,
15
+ createElementNode,
16
+ createInstance,
17
+ createRoot,
18
+ createTextInstance,
19
+ getHandler,
20
+ getRootOwner,
21
+ getTagConfig,
22
+ nodeAppendChild,
23
+ processEventProps,
24
+ removeChild,
25
+ renderErrorFallback,
26
+ syncRootInheritedWindowState,
27
+ updateInstanceProps,
28
+ updateTextContent,
29
+ } from '../host-ops.js';
30
+ import { PropId } from '@exact/core/protocol/opcodes';
31
+ import {
32
+ _resetInspectorState,
33
+ getRootNode,
34
+ getScrollMetrics,
35
+ resolveTargetDetails,
36
+ serializeLayout,
37
+ setScrollOffset,
38
+ subscribeAgentEvents,
39
+ } from '../inspector.js';
40
+ import { NodeKind, _resetNodeIdCounter } from '../nodes/index.js';
41
+ import {
42
+ _resetExactLocaleForTests,
43
+ NodeType,
44
+ setExactLocaleOverride,
45
+ } from '@exact/core';
46
+ import {
47
+ _resetExperienceRegistryForTests,
48
+ upsertFloatingPosition,
49
+ } from '@exact/core/agent';
50
+ import { _resetWindowRuntimeForTests } from '../../../../js/src/window-management.js';
51
+
52
+ beforeEach(() => {
53
+ vi.useFakeTimers();
54
+ (
55
+ globalThis as typeof globalThis & {
56
+ exact?: {
57
+ dispatch: ReturnType<typeof vi.fn>;
58
+ screenWidth: number;
59
+ screenHeight: number;
60
+ screenScale: number;
61
+ safeAreaInsets: {
62
+ top: number;
63
+ right: number;
64
+ bottom: number;
65
+ left: number;
66
+ };
67
+ };
68
+ }
69
+ ).exact = {
70
+ dispatch: vi.fn(),
71
+ screenWidth: 393,
72
+ screenHeight: 852,
73
+ screenScale: 2,
74
+ safeAreaInsets: {
75
+ top: 0,
76
+ right: 0,
77
+ bottom: 0,
78
+ left: 0,
79
+ },
80
+ };
81
+ _resetNodeIdCounter();
82
+ _clearHandlers();
83
+ _resetHostOpsState();
84
+ _resetInspectorState();
85
+ _resetExperienceRegistryForTests();
86
+ _resetExactLocaleForTests();
87
+ _resetWindowRuntimeForTests();
88
+ });
89
+
90
+ afterEach(() => {
91
+ vi.runOnlyPendingTimers();
92
+ vi.useRealTimers();
93
+ _resetHostOpsState();
94
+ _resetInspectorState();
95
+ _resetExperienceRegistryForTests();
96
+ _resetExactLocaleForTests();
97
+ _resetWindowRuntimeForTests();
98
+ delete (
99
+ globalThis as typeof globalThis & {
100
+ exact?: unknown;
101
+ }
102
+ ).exact;
103
+ });
104
+
105
+ describe('renderErrorFallback', () => {
106
+ it('replaces the container contents with a text fallback node', () => {
107
+ const root = createRoot();
108
+
109
+ renderErrorFallback(root, 'Render error: boom');
110
+
111
+ expect(root.children).toHaveLength(1);
112
+
113
+ const fallback = root.children[0];
114
+ expect(fallback.kind).toBe(NodeKind.Element);
115
+ expect(fallback.originalTag).toBe('Text');
116
+ expect(fallback.children).toHaveLength(1);
117
+
118
+ const fallbackText = fallback.children[0];
119
+ expect(fallbackText?.kind).toBe(NodeKind.Text);
120
+ if (fallbackText?.kind === NodeKind.Text) {
121
+ expect(fallbackText.text).toBe('Render error: boom');
122
+ }
123
+ });
124
+ });
125
+
126
+ describe('render-phase encoding', () => {
127
+ it('defers subtree creation until the committed placement step', () => {
128
+ const root = createRoot();
129
+ const encoder = _getEncoder();
130
+ encoder.reset();
131
+
132
+ const parent = createInstance('View', {
133
+ style: {
134
+ padding: 12,
135
+ },
136
+ });
137
+ const child = createTextInstance('Millbrae');
138
+
139
+ nodeAppendChild(parent, child);
140
+ expect(encoder.getOpCount()).toBe(0);
141
+
142
+ appendChild(root, parent);
143
+ expect(encoder.getOpCount()).toBeGreaterThan(0);
144
+ });
145
+
146
+ it('re-emits the root create op when flushing dirty root children', () => {
147
+ const root = createRoot();
148
+ const encoder = _getEncoder();
149
+ const createViewSpy = vi.spyOn(encoder, 'createView');
150
+
151
+ encoder.reset();
152
+ createViewSpy.mockClear();
153
+
154
+ const child = createInstance('View', {});
155
+ appendChild(root, child);
156
+ commitBatch(root);
157
+
158
+ expect(createViewSpy).toHaveBeenCalledWith(root.id, NodeType.View);
159
+ });
160
+
161
+ it('coalesces repeated root child changes into one commit-time sync', () => {
162
+ const root = createRoot();
163
+ const encoder = _getEncoder();
164
+ const setChildrenSpy = vi.spyOn(encoder, 'setChildren');
165
+
166
+ encoder.reset();
167
+ setChildrenSpy.mockClear();
168
+
169
+ const first = createInstance('View', {});
170
+ const second = createInstance('View', {});
171
+ const third = createInstance('View', {});
172
+
173
+ appendChild(root, first);
174
+ appendChild(root, second);
175
+ appendChild(root, third);
176
+
177
+ expect(setChildrenSpy).not.toHaveBeenCalled();
178
+
179
+ commitBatch(root);
180
+
181
+ expect(setChildrenSpy).toHaveBeenCalledTimes(1);
182
+ expect(setChildrenSpy).toHaveBeenCalledWith(root.id, [
183
+ first.id,
184
+ second.id,
185
+ third.id,
186
+ ]);
187
+ });
188
+
189
+ it('applies image resize mode as a native style override', () => {
190
+ const instance = createInstance('Image', {
191
+ source: 'https://example.com/hero.png',
192
+ objectFit: 'contain',
193
+ });
194
+
195
+ expect(instance.style.resizeMode).toBe('contain');
196
+ expect(instance.props.imageSource).toBe('https://example.com/hero.png');
197
+ });
198
+
199
+ it('re-resolves logical properties against inherited rtl direction on mount', () => {
200
+ const root = createRoot();
201
+ const parent = createInstance('View', {
202
+ style: {
203
+ direction: 'rtl',
204
+ },
205
+ });
206
+ const child = createInstance('View', {
207
+ style: {
208
+ marginStart: 12,
209
+ },
210
+ });
211
+
212
+ nodeAppendChild(parent, child);
213
+ appendChild(root, parent);
214
+
215
+ expect(child.style.marginRight).toEqual({ type: 'points', value: 12 });
216
+ expect(child.style.marginLeft).toBeUndefined();
217
+ });
218
+
219
+ it('applies container safe area to the direct root child and consumes it for descendants', () => {
220
+ const root = createRoot();
221
+ globalThis.__exactSafeAreaInsetsChanged?.(20, 0, 34, 0);
222
+
223
+ const parent = createInstance('View', {});
224
+ const childScroll = createInstance('ScrollView', {});
225
+
226
+ nodeAppendChild(parent, childScroll);
227
+ appendChild(root, parent);
228
+
229
+ expect(parent.safeAreaState?.appliedInsets).toMatchObject({
230
+ top: 20,
231
+ bottom: 34,
232
+ });
233
+ expect(parent.style.paddingTop).toEqual({ type: 'points', value: 20 });
234
+ expect(parent.style.paddingBottom).toEqual({ type: 'points', value: 34 });
235
+ expect(childScroll.safeAreaState?.appliedInsets.top).toBe(0);
236
+ expect(childScroll.safeAreaState?.appliedInsets.bottom).toBe(0);
237
+ });
238
+
239
+ it('uses React Native flex shrink defaults for RN-style elements', () => {
240
+ const instance = createInstance('View', {});
241
+
242
+ expect(instance.style.flexDirection).toBe('column');
243
+ expect(instance.style.flexShrink).toBe(0);
244
+ });
245
+
246
+ it('applies ScrollView content container layout style to native scroll children', () => {
247
+ const scroll = createInstance('ScrollView', {
248
+ style: {
249
+ width: 320,
250
+ flex: 1,
251
+ },
252
+ contentContainerStyle: {
253
+ gap: 16,
254
+ paddingBottom: 28,
255
+ },
256
+ });
257
+
258
+ expect(scroll.style.flexDirection).toBe('column');
259
+ expect(scroll.style.flexGrow).toBe(1);
260
+ expect(scroll.style.flexShrink).toBe(1);
261
+ expect(scroll.style.rowGap).toBe(16);
262
+ expect(scroll.style.columnGap).toBe(16);
263
+ expect(scroll.style.paddingBottom).toEqual({ type: 'points', value: 28 });
264
+ });
265
+
266
+ it('gives unheighted single-line inputs a native control minimum height', () => {
267
+ const input = createInstance('input', {});
268
+ const explicitHeight = createInstance('input', {
269
+ style: { height: 28 },
270
+ });
271
+ const multiline = createInstance('input', {
272
+ multiline: true,
273
+ });
274
+
275
+ expect(input.style.minHeight).toEqual({ type: 'points', value: 36 });
276
+ expect(explicitHeight.style.height).toEqual({ type: 'points', value: 28 });
277
+ expect(explicitHeight.style.minHeight).toBeUndefined();
278
+ expect(multiline.style.minHeight).toBeUndefined();
279
+ });
280
+
281
+ it('lifts multiline input line count into native style metadata', () => {
282
+ const explicitLines = createInstance('input', {
283
+ multiline: true,
284
+ numberOfLines: 3,
285
+ });
286
+ const implicitLines = createInstance('input', {
287
+ multiline: true,
288
+ });
289
+
290
+ expect(explicitLines.style.numberOfLines).toBe(3);
291
+ expect(explicitLines.style.minHeight).toBeUndefined();
292
+ expect(implicitLines.style.numberOfLines).toBe(2);
293
+ });
294
+
295
+ it('gives built-in native toggles a stable layout footprint', () => {
296
+ const toggle = createInstance('Toggle', {
297
+ value: false,
298
+ });
299
+ const explicitToggle = createInstance('Toggle', {
300
+ value: false,
301
+ style: {
302
+ width: 60,
303
+ height: 32,
304
+ },
305
+ });
306
+
307
+ expect(toggle.style.minWidth).toEqual({ type: 'points', value: 46 });
308
+ expect(toggle.style.minHeight).toEqual({ type: 'points', value: 28 });
309
+ expect(explicitToggle.style.width).toEqual({ type: 'points', value: 60 });
310
+ expect(explicitToggle.style.height).toEqual({ type: 'points', value: 32 });
311
+ expect(explicitToggle.style.minWidth).toBeUndefined();
312
+ expect(explicitToggle.style.minHeight).toBeUndefined();
313
+ });
314
+
315
+ it('approximates root flex children as viewport-filling when no native layout bridge is installed', () => {
316
+ if (globalThis.exact) {
317
+ globalThis.exact.screenWidth = 1164;
318
+ globalThis.exact.screenHeight = 721;
319
+ }
320
+ const root = createRoot();
321
+ const screen = createInstance('View', {
322
+ style: {
323
+ flex: 1,
324
+ minHeight: '100%',
325
+ width: '100%',
326
+ },
327
+ });
328
+
329
+ appendChild(root, screen);
330
+ commitBatch(root);
331
+
332
+ const layout = serializeLayout();
333
+ const screenFrame = layout.frames.find((frame) => frame.viewId === screen.id);
334
+ expect(screenFrame).toMatchObject({
335
+ x: 0,
336
+ y: 0,
337
+ width: 1164,
338
+ height: 721,
339
+ });
340
+ });
341
+
342
+ it('fills root ScrollView viewports when no explicit size is authored', () => {
343
+ if (globalThis.exact) {
344
+ globalThis.exact.screenWidth = 393;
345
+ globalThis.exact.screenHeight = 852;
346
+ }
347
+ const root = createRoot();
348
+ const scroll = createInstance('ScrollView', {
349
+ style: {
350
+ backgroundColor: '#050607',
351
+ },
352
+ });
353
+
354
+ appendChild(root, scroll);
355
+ commitBatch(root);
356
+
357
+ expect(scroll.style.flexGrow).toBe(1);
358
+ expect(scroll.style.flexShrink).toBe(1);
359
+ expect(scroll.style.flexBasis).toEqual({ type: 'points', value: 0 });
360
+ const layout = serializeLayout();
361
+ const scrollFrame = layout.frames.find((frame) => frame.viewId === scroll.id);
362
+ expect(scrollFrame).toMatchObject({
363
+ x: 0,
364
+ y: 0,
365
+ width: 393,
366
+ height: 852,
367
+ });
368
+ });
369
+
370
+ it('strips percent minHeight from root ScrollView so viewport fill still applies', () => {
371
+ if (globalThis.exact) {
372
+ globalThis.exact.screenWidth = 393;
373
+ globalThis.exact.screenHeight = 852;
374
+ }
375
+ const root = createRoot();
376
+ const scroll = createInstance('ScrollView', {
377
+ style: {
378
+ flex: 1,
379
+ width: '100%',
380
+ minHeight: '100%',
381
+ backgroundColor: '#050607',
382
+ },
383
+ });
384
+
385
+ appendChild(root, scroll);
386
+ commitBatch(root);
387
+
388
+ expect(scroll.style.minHeight).toBeUndefined();
389
+ expect(scroll.style.flexGrow).toBe(1);
390
+ expect(scroll.style.flexShrink).toBe(1);
391
+ expect(scroll.style.flexBasis).toEqual({ type: 'points', value: 0 });
392
+ const layout = serializeLayout();
393
+ const scrollFrame = layout.frames.find((frame) => frame.viewId === scroll.id);
394
+ expect(scrollFrame).toMatchObject({
395
+ x: 0,
396
+ y: 0,
397
+ width: 393,
398
+ height: 852,
399
+ });
400
+ });
401
+
402
+ it('preserves explicit root ScrollView viewport sizing', () => {
403
+ if (globalThis.exact) {
404
+ globalThis.exact.screenWidth = 393;
405
+ globalThis.exact.screenHeight = 852;
406
+ }
407
+ const root = createRoot();
408
+ const scroll = createInstance('ScrollView', {
409
+ style: {
410
+ height: 240,
411
+ backgroundColor: '#050607',
412
+ },
413
+ });
414
+
415
+ appendChild(root, scroll);
416
+ commitBatch(root);
417
+
418
+ expect(scroll.style.flexGrow).toBeUndefined();
419
+ expect(scroll.style.height).toEqual({ type: 'points', value: 240 });
420
+ const layout = serializeLayout();
421
+ const scrollFrame = layout.frames.find((frame) => frame.viewId === scroll.id);
422
+ expect(scrollFrame).toMatchObject({
423
+ x: 0,
424
+ y: 0,
425
+ width: 393,
426
+ height: 240,
427
+ });
428
+ });
429
+
430
+ it('includes native root scroll offsets in fallback agent target resolution', () => {
431
+ if (globalThis.exact) {
432
+ globalThis.exact.screenWidth = 320;
433
+ globalThis.exact.screenHeight = 200;
434
+ }
435
+ const root = createRoot();
436
+ const spacer = createInstance('View', {
437
+ style: {
438
+ height: 260,
439
+ },
440
+ });
441
+ const target = createInstance('Pressable', {
442
+ testID: 'bottom-row',
443
+ accessibilityLabel: 'Bottom row',
444
+ style: {
445
+ height: 40,
446
+ },
447
+ });
448
+
449
+ nodeAppendChild(root, spacer);
450
+ nodeAppendChild(root, target);
451
+ commitBatch(root);
452
+
453
+ const before = resolveTargetDetails({
454
+ rootId: root.rootId,
455
+ testId: 'bottom-row',
456
+ });
457
+ expect(before?.visibility).toBe('none');
458
+ expect(before?.scrollChain[0]).toMatchObject({
459
+ viewId: root.id,
460
+ type: 'Root',
461
+ currentOffset: { x: 0, y: 0 },
462
+ contentSize: { width: 320, height: 300 },
463
+ viewportSize: { width: 320, height: 200 },
464
+ });
465
+ expect(before?.recommendedScrollDelta).toMatchObject({
466
+ ancestor: {
467
+ viewId: root.id,
468
+ type: 'Root',
469
+ },
470
+ delta: { x: 0, y: 100 },
471
+ });
472
+
473
+ const sync = (
474
+ globalThis as typeof globalThis & {
475
+ __exactSetInspectorScrollOffset?: (
476
+ viewId: number,
477
+ offset: { x: number; y: number },
478
+ rootId?: number,
479
+ ) => { x: number; y: number } | null;
480
+ }
481
+ ).__exactSetInspectorScrollOffset;
482
+ expect(sync).toBeTypeOf('function');
483
+ expect(sync?.(root.id, { x: 0, y: 100 }, root.rootId)).toEqual({
484
+ x: 0,
485
+ y: 100,
486
+ });
487
+
488
+ const after = resolveTargetDetails({
489
+ rootId: root.rootId,
490
+ testId: 'bottom-row',
491
+ });
492
+ expect(after?.visibility).toBe('full');
493
+ expect(after?.frame).toMatchObject({
494
+ y: 160,
495
+ height: 40,
496
+ });
497
+ expect(after?.scrollChain[0]).toMatchObject({
498
+ viewId: root.id,
499
+ currentOffset: { x: 0, y: 100 },
500
+ });
501
+
502
+ setScrollOffset(root.id, { x: 0, y: 0 });
503
+ });
504
+
505
+ it('exposes ScrollView offsets and content metrics in agent diagnostics', () => {
506
+ if (globalThis.exact) {
507
+ globalThis.exact.screenWidth = 320;
508
+ globalThis.exact.screenHeight = 240;
509
+ }
510
+ const root = createRoot();
511
+ const scroll = createInstance('ScrollView', {
512
+ testID: 'feed-scroll',
513
+ style: {
514
+ width: 320,
515
+ height: 120,
516
+ },
517
+ });
518
+ const spacer = createInstance('View', {
519
+ style: {
520
+ height: 260,
521
+ },
522
+ });
523
+ const target = createInstance('Pressable', {
524
+ testID: 'feed-row-12',
525
+ accessibilityLabel: 'Feed row 12',
526
+ style: {
527
+ height: 44,
528
+ },
529
+ });
530
+
531
+ nodeAppendChild(scroll, spacer);
532
+ nodeAppendChild(scroll, target);
533
+ appendChild(root, scroll);
534
+ commitBatch(root);
535
+
536
+ const before = resolveTargetDetails({
537
+ rootId: root.rootId,
538
+ testId: 'feed-row-12',
539
+ });
540
+ expect(before?.visibility).toBe('none');
541
+ expect(before?.scrollChain[0]).toMatchObject({
542
+ viewId: scroll.id,
543
+ type: 'ScrollView',
544
+ currentOffset: { x: 0, y: 0 },
545
+ contentSize: { width: 320, height: 304 },
546
+ viewportSize: { width: 320, height: 120 },
547
+ });
548
+
549
+ setScrollOffset(scroll.id, { x: 0, y: 184 });
550
+
551
+ const metrics = getScrollMetrics(scroll.id, root.rootId);
552
+ expect(metrics).toMatchObject({
553
+ viewId: scroll.id,
554
+ currentOffset: { x: 0, y: 184 },
555
+ contentSize: { width: 320, height: 304 },
556
+ viewportSize: { width: 320, height: 120 },
557
+ });
558
+
559
+ const after = resolveTargetDetails({
560
+ rootId: root.rootId,
561
+ testId: 'feed-row-12',
562
+ });
563
+ expect(after?.visibility).toBe('full');
564
+ expect(after?.scrollChain[0]).toMatchObject({
565
+ viewId: scroll.id,
566
+ currentOffset: { x: 0, y: 184 },
567
+ });
568
+
569
+ setScrollOffset(scroll.id, { x: 0, y: 0 });
570
+ });
571
+
572
+ it('uses floating registry frames for fallback portal target resolution', () => {
573
+ if (globalThis.exact) {
574
+ globalThis.exact.screenWidth = 320;
575
+ globalThis.exact.screenHeight = 240;
576
+ }
577
+ const root = createRoot();
578
+ const section = createInstance('View', {
579
+ style: {
580
+ width: 320,
581
+ height: 220,
582
+ paddingLeft: 40,
583
+ paddingTop: 100,
584
+ },
585
+ });
586
+ const popover = createInstance('View', {
587
+ portalTarget: 'root',
588
+ testID: 'floating-popover',
589
+ style: {
590
+ position: 'absolute',
591
+ left: 80,
592
+ top: 120,
593
+ width: 200,
594
+ height: 120,
595
+ paddingLeft: 10,
596
+ paddingTop: 10,
597
+ },
598
+ });
599
+ const option = createInstance('Pressable', {
600
+ testID: 'floating-option',
601
+ style: {
602
+ width: 180,
603
+ height: 40,
604
+ },
605
+ });
606
+
607
+ nodeAppendChild(popover, option);
608
+ nodeAppendChild(section, popover);
609
+ appendChild(root, section);
610
+ upsertFloatingPosition({
611
+ id: `floating:${popover.id}`,
612
+ viewId: popover.id,
613
+ anchorViewId: section.id,
614
+ placement: 'bottom-start',
615
+ strategy: 'absolute',
616
+ x: 80,
617
+ y: 30,
618
+ width: 200,
619
+ height: 120,
620
+ positioned: true,
621
+ state: 'positioned',
622
+ scrolling: false,
623
+ updatedAt: 1,
624
+ });
625
+ commitBatch(root);
626
+
627
+ const layout = serializeLayout();
628
+ expect(layout.frames.find((frame) => frame.viewId === popover.id)).toMatchObject({
629
+ x: 80,
630
+ y: 30,
631
+ width: 200,
632
+ height: 120,
633
+ });
634
+ expect(layout.frames.find((frame) => frame.viewId === option.id)).toMatchObject({
635
+ x: 90,
636
+ y: 40,
637
+ width: 180,
638
+ height: 40,
639
+ });
640
+ expect(resolveTargetDetails({
641
+ rootId: root.rootId,
642
+ testId: 'floating-option',
643
+ })?.visibility).toBe('full');
644
+ });
645
+
646
+ it('shrink-wraps auto-width row children in fallback agent layout', () => {
647
+ if (globalThis.exact) {
648
+ globalThis.exact.screenWidth = 360;
649
+ globalThis.exact.screenHeight = 240;
650
+ }
651
+ const root = createRoot();
652
+ const row = createInstance('View', {
653
+ style: {
654
+ width: 320,
655
+ height: 80,
656
+ flexDirection: 'row',
657
+ gap: 8,
658
+ },
659
+ });
660
+ const flexible = createInstance('View', {
661
+ style: {
662
+ flex: 1,
663
+ },
664
+ });
665
+ const buttons = createInstance('View', {
666
+ style: {
667
+ flexDirection: 'row',
668
+ gap: 8,
669
+ },
670
+ });
671
+ const primary = createInstance('Pressable', {
672
+ accessibilityLabel: 'Primary',
673
+ style: {
674
+ width: 72,
675
+ height: 40,
676
+ },
677
+ });
678
+ const secondary = createInstance('Pressable', {
679
+ accessibilityLabel: 'Secondary',
680
+ style: {
681
+ width: 72,
682
+ height: 40,
683
+ },
684
+ });
685
+
686
+ nodeAppendChild(buttons, primary);
687
+ nodeAppendChild(buttons, secondary);
688
+ nodeAppendChild(row, flexible);
689
+ nodeAppendChild(row, buttons);
690
+ appendChild(root, row);
691
+ commitBatch(root);
692
+
693
+ const layout = serializeLayout();
694
+ const buttonsFrame = layout.frames.find((frame) => frame.viewId === buttons.id);
695
+ const secondaryFrame = layout.frames.find((frame) => frame.viewId === secondary.id);
696
+ expect(buttonsFrame).toMatchObject({
697
+ x: 168,
698
+ width: 152,
699
+ });
700
+ expect(secondaryFrame).toMatchObject({
701
+ x: 248,
702
+ width: 72,
703
+ });
704
+ });
705
+
706
+ it('wraps overflowing row children in fallback agent layout', () => {
707
+ if (globalThis.exact) {
708
+ globalThis.exact.screenWidth = 160;
709
+ globalThis.exact.screenHeight = 180;
710
+ }
711
+ const root = createRoot();
712
+ const row = createInstance('View', {
713
+ style: {
714
+ width: 120,
715
+ flexDirection: 'row',
716
+ flexWrap: 'wrap',
717
+ gap: 8,
718
+ },
719
+ });
720
+ const first = createInstance('Pressable', {
721
+ accessibilityLabel: 'First',
722
+ style: {
723
+ width: 56,
724
+ height: 24,
725
+ },
726
+ });
727
+ const second = createInstance('Pressable', {
728
+ accessibilityLabel: 'Second',
729
+ style: {
730
+ width: 56,
731
+ height: 24,
732
+ },
733
+ });
734
+ const third = createInstance('Pressable', {
735
+ accessibilityLabel: 'Third',
736
+ style: {
737
+ width: 56,
738
+ height: 24,
739
+ },
740
+ });
741
+
742
+ nodeAppendChild(row, first);
743
+ nodeAppendChild(row, second);
744
+ nodeAppendChild(row, third);
745
+ appendChild(root, row);
746
+ commitBatch(root);
747
+
748
+ const layout = serializeLayout();
749
+ expect(layout.frames.find((frame) => frame.viewId === row.id)).toMatchObject({
750
+ width: 120,
751
+ height: 56,
752
+ });
753
+ expect(layout.frames.find((frame) => frame.viewId === first.id)).toMatchObject({
754
+ x: 0,
755
+ y: 0,
756
+ width: 56,
757
+ height: 24,
758
+ });
759
+ expect(layout.frames.find((frame) => frame.viewId === second.id)).toMatchObject({
760
+ x: 64,
761
+ y: 0,
762
+ width: 56,
763
+ height: 24,
764
+ });
765
+ expect(layout.frames.find((frame) => frame.viewId === third.id)).toMatchObject({
766
+ x: 0,
767
+ y: 32,
768
+ width: 56,
769
+ height: 24,
770
+ });
771
+ });
772
+
773
+ it('stretches row children on the cross axis in fallback agent layout', () => {
774
+ if (globalThis.exact) {
775
+ globalThis.exact.screenWidth = 360;
776
+ globalThis.exact.screenHeight = 240;
777
+ }
778
+ const root = createRoot();
779
+ const row = createInstance('View', {
780
+ style: {
781
+ width: 320,
782
+ height: 80,
783
+ flexDirection: 'row',
784
+ },
785
+ });
786
+ const panel = createInstance('View', {
787
+ style: {
788
+ flex: 1,
789
+ minHeight: 0,
790
+ },
791
+ });
792
+ const tallContent = createInstance('View', {
793
+ style: {
794
+ width: 120,
795
+ height: 240,
796
+ },
797
+ });
798
+
799
+ nodeAppendChild(panel, tallContent);
800
+ nodeAppendChild(row, panel);
801
+ appendChild(root, row);
802
+ commitBatch(root);
803
+
804
+ const layout = serializeLayout();
805
+ const panelFrame = layout.frames.find((frame) => frame.viewId === panel.id);
806
+ expect(panelFrame).toMatchObject({
807
+ width: 320,
808
+ height: 80,
809
+ });
810
+ });
811
+
812
+ it('uses child content to size non-empty pressables in fallback agent layout', () => {
813
+ if (globalThis.exact) {
814
+ globalThis.exact.screenWidth = 360;
815
+ globalThis.exact.screenHeight = 240;
816
+ }
817
+ const root = createRoot();
818
+ const pressable = createInstance('Pressable', {
819
+ accessibilityLabel: 'Route card',
820
+ style: {
821
+ width: 280,
822
+ padding: 12,
823
+ gap: 8,
824
+ },
825
+ });
826
+ const title = createInstance('Text', {
827
+ children: 'Route title',
828
+ style: {
829
+ fontSize: 14,
830
+ },
831
+ });
832
+ const summary = createInstance('Text', {
833
+ children: 'Route summary',
834
+ style: {
835
+ fontSize: 12,
836
+ },
837
+ });
838
+
839
+ nodeAppendChild(pressable, title);
840
+ nodeAppendChild(pressable, summary);
841
+ appendChild(root, pressable);
842
+ commitBatch(root);
843
+
844
+ const layout = serializeLayout();
845
+ const pressableFrame = layout.frames.find((frame) => frame.viewId === pressable.id);
846
+ expect(pressableFrame?.height).toBeGreaterThan(44);
847
+ });
848
+
849
+ it('adds sibling safe-area inset expansion into remaining container insets', () => {
850
+ const root = createRoot();
851
+ globalThis.__exactSafeAreaInsetsChanged?.(0, 0, 34, 0);
852
+
853
+ const screen = createInstance('View', {
854
+ safeArea: 'none',
855
+ });
856
+ const content = createInstance('ScrollView', {});
857
+ const footer = createInstance('View', {
858
+ safeAreaInset: {
859
+ edge: 'bottom',
860
+ size: 56,
861
+ },
862
+ });
863
+
864
+ nodeAppendChild(screen, content);
865
+ nodeAppendChild(screen, footer);
866
+ appendChild(root, screen);
867
+
868
+ expect(content.safeAreaState?.siblingExpansion.bottom).toBe(56);
869
+ expect(content.safeAreaState?.appliedInsets.bottom).toBe(90);
870
+ });
871
+
872
+ it('does not consume container insets when a parent only opts into keyboard avoidance', () => {
873
+ const root = createRoot();
874
+ globalThis.__exactSafeAreaInsetsChanged?.(0, 0, 34, 0);
875
+ globalThis.__exactKeyboardStateChanged?.({
876
+ visible: true,
877
+ mode: 'docked',
878
+ progress: 1,
879
+ occlusion: {
880
+ x: 0,
881
+ y: 516,
882
+ width: 393,
883
+ height: 336,
884
+ },
885
+ });
886
+
887
+ const keyboardOnlyContainer = createInstance('View', {
888
+ safeArea: {
889
+ regions: 'keyboard',
890
+ },
891
+ });
892
+ const containerChild = createInstance('View', {
893
+ safeArea: {
894
+ regions: 'container',
895
+ },
896
+ });
897
+
898
+ nodeAppendChild(keyboardOnlyContainer, containerChild);
899
+ appendChild(root, keyboardOnlyContainer);
900
+
901
+ expect(keyboardOnlyContainer.safeAreaState?.appliedInsets.bottom).toBe(336);
902
+ expect(containerChild.safeAreaState?.sourceRegions.container.bottom).toBe(34);
903
+ expect(containerChild.safeAreaState?.appliedInsets.bottom).toBe(34);
904
+ });
905
+ });
906
+
907
+ describe('processEventProps', () => {
908
+ it('binds TextInput change events and normalizes native text payloads', () => {
909
+ const onChangeText = vi.fn();
910
+ const onChange = vi.fn();
911
+ const instance = createElementNode('input', 'TextInput', true, {});
912
+ const config = getTagConfig('TextInput');
913
+
914
+ expect(config).not.toBeNull();
915
+ processEventProps(instance, { onChangeText, onChange }, config!);
916
+
917
+ const binding = instance.events.get(EventType.Change);
918
+ expect(binding).toBeDefined();
919
+
920
+ const dispatch = (
921
+ globalThis as {
922
+ __exactDispatchEvent?: (handlerId: number, payload: unknown) => void;
923
+ }
924
+ ).__exactDispatchEvent;
925
+
926
+ expect(dispatch).toBeTypeOf('function');
927
+
928
+ dispatch!(binding!.handlerId, {
929
+ nodeId: instance.id,
930
+ text: 'Millbrae',
931
+ value: 'Millbrae',
932
+ textRevision: 3,
933
+ classification: 'accepted-transaction',
934
+ });
935
+
936
+ expect(onChangeText).toHaveBeenCalledWith('Millbrae');
937
+ expect(onChange).toHaveBeenCalledWith({
938
+ nativeEvent: {
939
+ text: 'Millbrae',
940
+ textRevision: 3,
941
+ classification: 'accepted-transaction',
942
+ textChanged: true,
943
+ },
944
+ });
945
+ });
946
+
947
+ it('dispatches TextInput submit editing from native Enter keydown', () => {
948
+ const onSubmitEditing = vi.fn();
949
+ const onKeyDown = vi.fn();
950
+ const instance = createElementNode('input', 'TextInput', true, {});
951
+ const config = getTagConfig('TextInput');
952
+
953
+ expect(config).not.toBeNull();
954
+ processEventProps(instance, { onSubmitEditing, onKeyDown }, config!);
955
+
956
+ const binding = instance.events.get(EventType.KeyDown);
957
+ expect(binding).toBeDefined();
958
+
959
+ const dispatch = (
960
+ globalThis as {
961
+ __exactDispatchEvent?: (handlerId: number, payload: unknown) => void;
962
+ }
963
+ ).__exactDispatchEvent;
964
+
965
+ expect(dispatch).toBeTypeOf('function');
966
+
967
+ dispatch!(binding!.handlerId, {
968
+ nodeId: instance.id,
969
+ nativeEvent: { key: 'a', code: 'KeyA' },
970
+ });
971
+ expect(onKeyDown).toHaveBeenCalledTimes(1);
972
+ expect(onSubmitEditing).not.toHaveBeenCalled();
973
+
974
+ dispatch!(binding!.handlerId, {
975
+ nodeId: instance.id,
976
+ nativeEvent: { key: 'Enter', code: 'Enter' },
977
+ });
978
+ expect(onKeyDown).toHaveBeenCalledTimes(2);
979
+ expect(onSubmitEditing).toHaveBeenCalledTimes(1);
980
+ });
981
+
982
+ it('does not dispatch TextInput submit editing for multiline Enter', () => {
983
+ const onSubmitEditing = vi.fn();
984
+ const instance = createElementNode('input', 'TextInput', true, {});
985
+ const config = getTagConfig('TextInput');
986
+
987
+ expect(config).not.toBeNull();
988
+ processEventProps(instance, { multiline: true, onSubmitEditing }, config!);
989
+
990
+ const binding = instance.events.get(EventType.KeyDown);
991
+ expect(binding).toBeDefined();
992
+
993
+ const dispatch = (
994
+ globalThis as {
995
+ __exactDispatchEvent?: (handlerId: number, payload: unknown) => void;
996
+ }
997
+ ).__exactDispatchEvent;
998
+
999
+ expect(dispatch).toBeTypeOf('function');
1000
+
1001
+ dispatch!(binding!.handlerId, {
1002
+ nodeId: instance.id,
1003
+ nativeEvent: { key: 'Enter', code: 'Enter' },
1004
+ });
1005
+ expect(onSubmitEditing).not.toHaveBeenCalled();
1006
+ });
1007
+
1008
+ it('dispatches TextInput selection events without reporting text edits', () => {
1009
+ const onChangeText = vi.fn();
1010
+ const onSelectionChange = vi.fn();
1011
+ const instance = createElementNode('input', 'TextInput', true, {});
1012
+ const config = getTagConfig('TextInput');
1013
+
1014
+ expect(config).not.toBeNull();
1015
+ processEventProps(instance, { onChangeText, onSelectionChange }, config!);
1016
+
1017
+ const binding = instance.events.get(EventType.Change);
1018
+ expect(binding).toBeDefined();
1019
+
1020
+ const dispatch = (
1021
+ globalThis as {
1022
+ __exactDispatchEvent?: (handlerId: number, payload: unknown) => void;
1023
+ }
1024
+ ).__exactDispatchEvent;
1025
+
1026
+ expect(dispatch).toBeTypeOf('function');
1027
+
1028
+ dispatch!(binding!.handlerId, {
1029
+ nodeId: instance.id,
1030
+ text: 'Millbrae',
1031
+ selectionStart: 2,
1032
+ selectionEnd: 6,
1033
+ textChanged: false,
1034
+ textRevision: 4,
1035
+ classification: 'current-echo',
1036
+ });
1037
+
1038
+ expect(onChangeText).not.toHaveBeenCalled();
1039
+ expect(onSelectionChange).toHaveBeenCalledWith({
1040
+ nativeEvent: {
1041
+ selection: {
1042
+ start: 2,
1043
+ end: 6,
1044
+ },
1045
+ text: 'Millbrae',
1046
+ textRevision: 4,
1047
+ classification: 'current-echo',
1048
+ },
1049
+ });
1050
+ });
1051
+
1052
+ it('suppresses committed TextInput change callbacks during IME composition', () => {
1053
+ const onChangeText = vi.fn();
1054
+ const onChange = vi.fn();
1055
+ const onSelectionChange = vi.fn();
1056
+ const instance = createElementNode('input', 'TextInput', true, {});
1057
+ const config = getTagConfig('TextInput');
1058
+
1059
+ expect(config).not.toBeNull();
1060
+ processEventProps(instance, { onChangeText, onChange, onSelectionChange }, config!);
1061
+
1062
+ const binding = instance.events.get(EventType.Change);
1063
+ expect(binding).toBeDefined();
1064
+
1065
+ const dispatch = (
1066
+ globalThis as {
1067
+ __exactDispatchEvent?: (handlerId: number, payload: unknown) => void;
1068
+ }
1069
+ ).__exactDispatchEvent;
1070
+
1071
+ expect(dispatch).toBeTypeOf('function');
1072
+
1073
+ dispatch!(binding!.handlerId, {
1074
+ nodeId: instance.id,
1075
+ text: 'とうきょう',
1076
+ selectionStart: 5,
1077
+ selectionEnd: 5,
1078
+ isComposing: true,
1079
+ compositionStart: 0,
1080
+ compositionEnd: 5,
1081
+ textChanged: true,
1082
+ textRevision: 5,
1083
+ classification: 'accepted-transaction',
1084
+ });
1085
+
1086
+ expect(onChangeText).not.toHaveBeenCalled();
1087
+ expect(onChange).not.toHaveBeenCalled();
1088
+ expect(onSelectionChange).toHaveBeenCalledWith({
1089
+ nativeEvent: {
1090
+ selection: {
1091
+ start: 5,
1092
+ end: 5,
1093
+ },
1094
+ text: 'とうきょう',
1095
+ isComposing: true,
1096
+ compositionRange: {
1097
+ start: 0,
1098
+ end: 5,
1099
+ },
1100
+ textRevision: 5,
1101
+ classification: 'accepted-transaction',
1102
+ },
1103
+ });
1104
+ });
1105
+
1106
+ it('binds native toggle onValueChange through native-view change events', () => {
1107
+ const onValueChange = vi.fn();
1108
+ const onChange = vi.fn();
1109
+ const toggle = createInstance('toggle', {
1110
+ value: false,
1111
+ onValueChange,
1112
+ onChange,
1113
+ });
1114
+ processEventProps(toggle, toggle.originalProps, getTagConfig('toggle')!);
1115
+
1116
+ const binding = toggle.events.get(EventType.Change);
1117
+ expect(binding).toBeDefined();
1118
+
1119
+ dispatchSyntheticEvent(toggle, EventType.Change, { value: true });
1120
+
1121
+ expect(onValueChange).toHaveBeenCalledWith(true);
1122
+ expect(onChange).toHaveBeenCalledWith({ value: true });
1123
+ });
1124
+
1125
+ it('binds Image lifecycle callbacks through native change events', () => {
1126
+ const onLoadStart = vi.fn();
1127
+ const onLoad = vi.fn();
1128
+ const onDisplay = vi.fn();
1129
+ const onError = vi.fn();
1130
+ const instance = createElementNode('image', 'Image', true, {});
1131
+ const config = getTagConfig('Image');
1132
+
1133
+ expect(config).not.toBeNull();
1134
+ processEventProps(
1135
+ instance,
1136
+ { onLoadStart, onLoad, onDisplay, onError },
1137
+ config!,
1138
+ );
1139
+
1140
+ const binding = instance.events.get(EventType.Change);
1141
+ expect(binding).toBeDefined();
1142
+
1143
+ const dispatch = (
1144
+ globalThis as {
1145
+ __exactDispatchEvent?: (handlerId: number, payload: unknown) => void;
1146
+ }
1147
+ ).__exactDispatchEvent;
1148
+
1149
+ expect(dispatch).toBeTypeOf('function');
1150
+
1151
+ dispatch!(binding!.handlerId, {
1152
+ event: 'loadStart',
1153
+ src: 'https://example.com/hero.png',
1154
+ });
1155
+ dispatch!(binding!.handlerId, {
1156
+ nativeEvent: {
1157
+ event: 'load',
1158
+ src: 'https://example.com/hero.png',
1159
+ width: 640,
1160
+ height: 360,
1161
+ cacheType: 'memory',
1162
+ },
1163
+ });
1164
+ dispatch!(binding!.handlerId, {
1165
+ event: 'display',
1166
+ });
1167
+ dispatch!(binding!.handlerId, {
1168
+ event: 'error',
1169
+ error: 'Image failed to load.',
1170
+ });
1171
+
1172
+ expect(onLoadStart).toHaveBeenCalledTimes(1);
1173
+ expect(onLoad).toHaveBeenCalledWith({
1174
+ source: {
1175
+ uri: 'https://example.com/hero.png',
1176
+ width: 640,
1177
+ height: 360,
1178
+ },
1179
+ cacheType: 'memory',
1180
+ });
1181
+ expect(onDisplay).toHaveBeenCalledTimes(1);
1182
+ expect(onError).toHaveBeenCalledWith({
1183
+ error: 'Image failed to load.',
1184
+ });
1185
+ });
1186
+
1187
+ it('throttles ScrollView scroll events when scrollEventThrottle is set', () => {
1188
+ const onScroll = vi.fn();
1189
+ const instance = createElementNode('scroll', 'ScrollView', true, {});
1190
+ const config = getTagConfig('ScrollView');
1191
+
1192
+ expect(config).not.toBeNull();
1193
+ processEventProps(
1194
+ instance,
1195
+ {
1196
+ onScroll,
1197
+ scrollEventThrottle: 16,
1198
+ },
1199
+ config!,
1200
+ );
1201
+
1202
+ const binding = instance.events.get(EventType.Scroll);
1203
+ expect(binding).toBeDefined();
1204
+
1205
+ const dispatch = (
1206
+ globalThis as {
1207
+ __exactDispatchEvent?: (handlerId: number, payload: unknown) => void;
1208
+ }
1209
+ ).__exactDispatchEvent;
1210
+
1211
+ expect(dispatch).toBeTypeOf('function');
1212
+
1213
+ dispatch!(binding!.handlerId, {
1214
+ x: 0,
1215
+ y: 0,
1216
+ nativeEvent: { timestamp: 0 },
1217
+ });
1218
+ dispatch!(binding!.handlerId, {
1219
+ x: 0,
1220
+ y: 8,
1221
+ nativeEvent: { timestamp: 8 },
1222
+ });
1223
+ dispatch!(binding!.handlerId, {
1224
+ x: 0,
1225
+ y: 16,
1226
+ nativeEvent: { timestamp: 16 },
1227
+ });
1228
+
1229
+ expect(onScroll).toHaveBeenCalledTimes(2);
1230
+ expect(onScroll.mock.calls.map(([event]) => event.nativeEvent.contentOffset.y)).toEqual([
1231
+ 0,
1232
+ 16,
1233
+ ]);
1234
+ });
1235
+
1236
+ it('updates ScrollView throttling when only scrollEventThrottle changes', () => {
1237
+ const onScroll = vi.fn();
1238
+ const instance = createInstance('ScrollView', {
1239
+ onScroll,
1240
+ scrollEventThrottle: 16,
1241
+ });
1242
+ // Event registration is deferred to commit (LLP 0159); simulate it here.
1243
+ processEventProps(instance, instance.originalProps, getTagConfig('ScrollView')!);
1244
+
1245
+ const binding = instance.events.get(EventType.Scroll);
1246
+ expect(binding).toBeDefined();
1247
+
1248
+ updateInstanceProps(
1249
+ instance,
1250
+ {
1251
+ onScroll,
1252
+ scrollEventThrottle: 0,
1253
+ },
1254
+ {
1255
+ onScroll,
1256
+ scrollEventThrottle: 0,
1257
+ },
1258
+ );
1259
+
1260
+ const dispatch = (
1261
+ globalThis as {
1262
+ __exactDispatchEvent?: (handlerId: number, payload: unknown) => void;
1263
+ }
1264
+ ).__exactDispatchEvent;
1265
+
1266
+ expect(dispatch).toBeTypeOf('function');
1267
+
1268
+ dispatch!(binding!.handlerId, {
1269
+ x: 0,
1270
+ y: 0,
1271
+ nativeEvent: { timestamp: 0 },
1272
+ });
1273
+ dispatch!(binding!.handlerId, {
1274
+ x: 0,
1275
+ y: 8,
1276
+ nativeEvent: { timestamp: 8 },
1277
+ });
1278
+
1279
+ expect(onScroll).toHaveBeenCalledTimes(2);
1280
+ });
1281
+ });
1282
+
1283
+ describe('updateInstanceProps', () => {
1284
+ it('refreshes press handlers against the instance stored props', () => {
1285
+ const firstHandler = vi.fn();
1286
+ const secondHandler = vi.fn();
1287
+ const instance = createInstance('Pressable', {
1288
+ onPress: firstHandler,
1289
+ });
1290
+ // Event registration is deferred to commit (LLP 0159); simulate it here.
1291
+ processEventProps(instance, instance.originalProps, getTagConfig('Pressable')!);
1292
+
1293
+ const binding = instance.events.get(EventType.Press);
1294
+ expect(binding).toBeDefined();
1295
+
1296
+ updateInstanceProps(
1297
+ instance,
1298
+ {
1299
+ onPress: secondHandler,
1300
+ },
1301
+ {
1302
+ onPress: secondHandler,
1303
+ },
1304
+ );
1305
+
1306
+ const dispatch = (
1307
+ globalThis as {
1308
+ __exactDispatchEvent?: (handlerId: number, payload: unknown) => void;
1309
+ }
1310
+ ).__exactDispatchEvent;
1311
+
1312
+ expect(dispatch).toBeTypeOf('function');
1313
+
1314
+ dispatch!(binding!.handlerId, {
1315
+ nativeEvent: {
1316
+ timestamp: Date.now(),
1317
+ },
1318
+ });
1319
+
1320
+ expect(firstHandler).not.toHaveBeenCalled();
1321
+ expect(secondHandler).toHaveBeenCalledTimes(1);
1322
+ });
1323
+
1324
+ it('routes transform-only updates through the transform patch opcode', () => {
1325
+ const root = createRoot();
1326
+ const encoder = _getEncoder();
1327
+ const instance = createInstance('View', {
1328
+ style: {
1329
+ width: 100,
1330
+ height: 50,
1331
+ transform: [{ translateX: 0 }, { translateY: 0 }],
1332
+ },
1333
+ });
1334
+
1335
+ appendChild(root, instance);
1336
+ encoder.reset();
1337
+
1338
+ const setStyleSpy = vi.spyOn(encoder, 'setStyle');
1339
+ const setTransformSpy = vi.spyOn(encoder, 'setTransform');
1340
+
1341
+ updateInstanceProps(
1342
+ instance,
1343
+ {
1344
+ style: {
1345
+ width: 100,
1346
+ height: 50,
1347
+ transform: [{ translateX: 0 }, { translateY: 0 }],
1348
+ },
1349
+ },
1350
+ {
1351
+ style: {
1352
+ width: 100,
1353
+ height: 50,
1354
+ transform: [{ translateX: 12 }, { translateY: 18 }],
1355
+ },
1356
+ },
1357
+ );
1358
+
1359
+ expect(setStyleSpy).not.toHaveBeenCalled();
1360
+ expect(setTransformSpy).toHaveBeenCalledOnce();
1361
+ expect(setTransformSpy).toHaveBeenCalledWith(instance.id, 12, 18, 1, 0);
1362
+ expect(encoder.getOpCount()).toBe(1);
1363
+ });
1364
+
1365
+ it('routes background-color-only updates through the color patch opcode', () => {
1366
+ const root = createRoot();
1367
+ const encoder = _getEncoder();
1368
+ const instance = createInstance('View', {
1369
+ style: {
1370
+ width: 100,
1371
+ height: 50,
1372
+ backgroundColor: '#ffffff',
1373
+ },
1374
+ });
1375
+
1376
+ appendChild(root, instance);
1377
+ encoder.reset();
1378
+
1379
+ const setStyleSpy = vi.spyOn(encoder, 'setStyle');
1380
+ const setBackgroundColorSpy = vi.spyOn(encoder, 'setBackgroundColor');
1381
+
1382
+ updateInstanceProps(
1383
+ instance,
1384
+ instance.originalProps,
1385
+ {
1386
+ ...instance.originalProps,
1387
+ style: {
1388
+ ...(instance.originalProps.style as Record<string, unknown>),
1389
+ backgroundColor: '#112233',
1390
+ },
1391
+ },
1392
+ );
1393
+
1394
+ expect(setStyleSpy).not.toHaveBeenCalled();
1395
+ expect(setBackgroundColorSpy).toHaveBeenCalledOnce();
1396
+ expect(setBackgroundColorSpy).toHaveBeenCalledWith(instance.id, {
1397
+ r: 17,
1398
+ g: 34,
1399
+ b: 51,
1400
+ a: 255,
1401
+ });
1402
+ expect(encoder.getOpCount()).toBe(1);
1403
+ });
1404
+
1405
+ it('does not request layout for transform-only commits', () => {
1406
+ const root = createRoot();
1407
+ const encoder = _getEncoder();
1408
+ const instance = createInstance('View', {
1409
+ style: {
1410
+ width: 100,
1411
+ height: 50,
1412
+ transform: [{ translateX: 0 }, { translateY: 0 }],
1413
+ },
1414
+ });
1415
+
1416
+ appendChild(root, instance);
1417
+ commitBatch(root);
1418
+ encoder.reset();
1419
+
1420
+ const computeLayoutSpy = vi.spyOn(encoder, 'computeLayout');
1421
+ const setChildrenSpy = vi.spyOn(encoder, 'setChildren');
1422
+
1423
+ updateInstanceProps(
1424
+ instance,
1425
+ {
1426
+ style: {
1427
+ width: 100,
1428
+ height: 50,
1429
+ transform: [{ translateX: 0 }, { translateY: 0 }],
1430
+ },
1431
+ },
1432
+ {
1433
+ style: {
1434
+ width: 100,
1435
+ height: 50,
1436
+ transform: [{ translateX: 12 }, { translateY: 18 }],
1437
+ },
1438
+ },
1439
+ );
1440
+ commitBatch(root);
1441
+
1442
+ expect(computeLayoutSpy).not.toHaveBeenCalled();
1443
+ expect(setChildrenSpy).not.toHaveBeenCalled();
1444
+ });
1445
+
1446
+ it('reports only changed view ids for transform-only render events', () => {
1447
+ const root = createRoot();
1448
+ const instance = createInstance('View', {
1449
+ style: {
1450
+ width: 100,
1451
+ height: 50,
1452
+ transform: [{ translateX: 0 }, { translateY: 0 }],
1453
+ },
1454
+ });
1455
+ const sibling = createInstance('View', {
1456
+ style: {
1457
+ width: 100,
1458
+ height: 50,
1459
+ },
1460
+ });
1461
+ const renderEvents: Array<{ changedViews: number[] }> = [];
1462
+ const unsubscribe = subscribeAgentEvents((event) => {
1463
+ if (event.type === 'render') {
1464
+ renderEvents.push({ changedViews: event.changedViews });
1465
+ }
1466
+ });
1467
+
1468
+ appendChild(root, instance);
1469
+ appendChild(root, sibling);
1470
+ commitBatch(root);
1471
+ renderEvents.length = 0;
1472
+
1473
+ updateInstanceProps(
1474
+ instance,
1475
+ instance.originalProps,
1476
+ {
1477
+ ...instance.originalProps,
1478
+ style: {
1479
+ ...(instance.originalProps.style as Record<string, unknown>),
1480
+ transform: [{ translateX: 12 }, { translateY: 18 }],
1481
+ },
1482
+ },
1483
+ );
1484
+ commitBatch(root);
1485
+ unsubscribe();
1486
+
1487
+ expect(renderEvents).toHaveLength(1);
1488
+ expect(renderEvents[0]?.changedViews).toEqual([instance.id]);
1489
+ });
1490
+
1491
+ it('still requests layout when text content changes', () => {
1492
+ const root = createRoot();
1493
+ const encoder = _getEncoder();
1494
+ const text = createTextInstance('Before');
1495
+ const label = createInstance('Text', {});
1496
+
1497
+ nodeAppendChild(label, text);
1498
+ appendChild(root, label);
1499
+ commitBatch(root);
1500
+ encoder.reset();
1501
+
1502
+ const computeLayoutSpy = vi.spyOn(encoder, 'computeLayout');
1503
+
1504
+ updateTextContent(text, 'After');
1505
+ commitBatch(root);
1506
+
1507
+ expect(computeLayoutSpy).toHaveBeenCalledOnce();
1508
+ });
1509
+
1510
+ it('copies high-word text measurement styles onto raw text leaves', () => {
1511
+ const root = createRoot();
1512
+ const encoder = _getEncoder();
1513
+ const text = createTextInstance('Italic child');
1514
+ const label = createInstance('Text', {
1515
+ style: {
1516
+ fontSize: 18,
1517
+ fontStyle: 'italic',
1518
+ letterSpacing: 1.5,
1519
+ direction: 'rtl',
1520
+ },
1521
+ });
1522
+
1523
+ const setStyleSpy = vi.spyOn(encoder, 'setStyle');
1524
+ nodeAppendChild(label, text);
1525
+ appendChild(root, label);
1526
+ commitBatch(root);
1527
+
1528
+ expect(setStyleSpy).toHaveBeenCalledWith(
1529
+ text.id,
1530
+ expect.objectContaining({
1531
+ fontSize: 18,
1532
+ fontStyle: 'italic',
1533
+ letterSpacing: 1.5,
1534
+ direction: 'rtl',
1535
+ }),
1536
+ );
1537
+ });
1538
+
1539
+ it('copies inherited text measurement styles when raw text leaves are appended incrementally', () => {
1540
+ const root = createRoot();
1541
+ const encoder = _getEncoder();
1542
+ const label = createInstance('Text', {
1543
+ style: {
1544
+ fontSize: 30,
1545
+ fontWeight: 800,
1546
+ lineHeight: 36,
1547
+ letterSpacing: -0.4,
1548
+ },
1549
+ });
1550
+
1551
+ appendChild(root, label);
1552
+ commitBatch(root);
1553
+ encoder.reset();
1554
+
1555
+ const text = createTextInstance('Writing');
1556
+ const setStyleSpy = vi.spyOn(encoder, 'setStyle');
1557
+ appendChild(label, text);
1558
+ commitBatch(root);
1559
+
1560
+ expect(setStyleSpy).toHaveBeenCalledWith(
1561
+ text.id,
1562
+ expect.objectContaining({
1563
+ fontSize: 30,
1564
+ fontWeight: 800,
1565
+ lineHeight: 36,
1566
+ letterSpacing: -0.4,
1567
+ }),
1568
+ );
1569
+ });
1570
+
1571
+ it('copies inherited text measurement styles onto nested text elements', () => {
1572
+ const root = createRoot();
1573
+ const encoder = _getEncoder();
1574
+ const child = createInstance('Text', {
1575
+ children: 'How it works',
1576
+ });
1577
+ const label = createInstance('Text', {
1578
+ style: {
1579
+ fontSize: 34,
1580
+ fontWeight: 800,
1581
+ lineHeight: 40,
1582
+ fontFamily: '"Avenir Next", Inter, ui-sans-serif, system-ui, sans-serif',
1583
+ },
1584
+ });
1585
+
1586
+ const setStyleSpy = vi.spyOn(encoder, 'setStyle');
1587
+ nodeAppendChild(label, child);
1588
+ appendChild(root, label);
1589
+ commitBatch(root);
1590
+
1591
+ expect(setStyleSpy).toHaveBeenCalledWith(
1592
+ child.id,
1593
+ expect.objectContaining({
1594
+ fontSize: 34,
1595
+ fontWeight: 800,
1596
+ lineHeight: 40,
1597
+ fontFamily: 1,
1598
+ }),
1599
+ );
1600
+
1601
+ setStyleSpy.mockClear();
1602
+ updateInstanceProps(label, label.originalProps, {
1603
+ ...label.originalProps,
1604
+ style: {
1605
+ fontSize: 34,
1606
+ fontWeight: 800,
1607
+ lineHeight: 44,
1608
+ fontFamily: '"Avenir Next", Inter, ui-sans-serif, system-ui, sans-serif',
1609
+ },
1610
+ });
1611
+
1612
+ expect(setStyleSpy).toHaveBeenCalledWith(
1613
+ child.id,
1614
+ expect.objectContaining({
1615
+ fontSize: 34,
1616
+ fontWeight: 800,
1617
+ lineHeight: 44,
1618
+ }),
1619
+ );
1620
+ });
1621
+
1622
+ it('carries inherited text measurement styles through inline pressables', () => {
1623
+ const root = createRoot();
1624
+ const encoder = _getEncoder();
1625
+ const linkText = createInstance('Text', {
1626
+ children: 'Linked Literate Programming',
1627
+ style: {
1628
+ fontWeight: 700,
1629
+ },
1630
+ });
1631
+ const link = createInstance('Pressable', {});
1632
+ const paragraph = createInstance('Text', {
1633
+ style: {
1634
+ fontSize: 16,
1635
+ lineHeight: 28,
1636
+ fontFamily: '"Avenir Next", Inter, ui-sans-serif, system-ui, sans-serif',
1637
+ },
1638
+ });
1639
+
1640
+ const setStyleSpy = vi.spyOn(encoder, 'setStyle');
1641
+ nodeAppendChild(link, linkText);
1642
+ nodeAppendChild(paragraph, link);
1643
+ appendChild(root, paragraph);
1644
+ commitBatch(root);
1645
+
1646
+ expect(setStyleSpy).toHaveBeenCalledWith(
1647
+ linkText.id,
1648
+ expect.objectContaining({
1649
+ fontSize: 16,
1650
+ fontWeight: 700,
1651
+ lineHeight: 28,
1652
+ fontFamily: 1,
1653
+ }),
1654
+ );
1655
+ });
1656
+
1657
+ it('patches explicitly stable fixed-size text content without layout', () => {
1658
+ const root = createRoot();
1659
+ const encoder = _getEncoder();
1660
+ const label = createInstance('Text', {
1661
+ __exactTextLayoutStable: true,
1662
+ style: {
1663
+ width: 80,
1664
+ height: 24,
1665
+ fontSize: 18,
1666
+ },
1667
+ children: 'Before',
1668
+ });
1669
+
1670
+ appendChild(root, label);
1671
+ commitBatch(root);
1672
+ encoder.reset();
1673
+
1674
+ const computeLayoutSpy = vi.spyOn(encoder, 'computeLayout');
1675
+ const setPropStringSpy = vi.spyOn(encoder, 'setPropString');
1676
+ const previousProps = label.originalProps;
1677
+
1678
+ updateInstanceProps(label, previousProps, {
1679
+ ...previousProps,
1680
+ children: 'After',
1681
+ });
1682
+ commitBatch(root);
1683
+
1684
+ expect(computeLayoutSpy).not.toHaveBeenCalled();
1685
+ expect(setPropStringSpy).toHaveBeenCalledWith(label.id, 0, 'After');
1686
+ expect(setPropStringSpy).toHaveBeenCalledTimes(1);
1687
+ });
1688
+
1689
+ it('patches explicit textContent props without layout for stable fixed-size text', () => {
1690
+ const root = createRoot();
1691
+ const encoder = _getEncoder();
1692
+ const label = createInstance('Text', {
1693
+ __exactTextLayoutStable: true,
1694
+ selectable: false,
1695
+ textContent: 'Before',
1696
+ style: {
1697
+ width: 80,
1698
+ height: 24,
1699
+ fontSize: 18,
1700
+ },
1701
+ });
1702
+
1703
+ appendChild(root, label);
1704
+ commitBatch(root);
1705
+ encoder.reset();
1706
+
1707
+ const computeLayoutSpy = vi.spyOn(encoder, 'computeLayout');
1708
+ const setPropStringSpy = vi.spyOn(encoder, 'setPropString');
1709
+ const previousProps = label.originalProps;
1710
+
1711
+ updateInstanceProps(label, previousProps, {
1712
+ ...previousProps,
1713
+ textContent: 'After',
1714
+ });
1715
+ commitBatch(root);
1716
+
1717
+ expect(computeLayoutSpy).not.toHaveBeenCalled();
1718
+ expect(setPropStringSpy).toHaveBeenCalledWith(label.id, 0, 'After');
1719
+ expect(setPropStringSpy).toHaveBeenCalledTimes(1);
1720
+ });
1721
+
1722
+ it('invalidates descendants when inherited direction changes', () => {
1723
+ const root = createRoot();
1724
+ const encoder = _getEncoder();
1725
+ const parent = createInstance('View', {
1726
+ style: {
1727
+ direction: 'ltr',
1728
+ },
1729
+ });
1730
+ const child = createInstance('View', {
1731
+ style: {
1732
+ marginStart: 10,
1733
+ },
1734
+ });
1735
+
1736
+ nodeAppendChild(parent, child);
1737
+ appendChild(root, parent);
1738
+ encoder.reset();
1739
+
1740
+ updateInstanceProps(
1741
+ parent,
1742
+ {
1743
+ style: {
1744
+ direction: 'ltr',
1745
+ },
1746
+ },
1747
+ {
1748
+ style: {
1749
+ direction: 'rtl',
1750
+ },
1751
+ },
1752
+ );
1753
+
1754
+ expect(child.style.marginRight).toEqual({ type: 'points', value: 10 });
1755
+ expect(child.style.marginLeft).toBeUndefined();
1756
+ expect(encoder.getOpCount()).toBeGreaterThanOrEqual(2);
1757
+ });
1758
+
1759
+ it('lifts top-level numberOfLines into canonical text style', () => {
1760
+ const instance = createInstance('Text', {
1761
+ children: 'Clamped paragraph',
1762
+ numberOfLines: 2,
1763
+ style: {
1764
+ fontSize: 14,
1765
+ lineHeight: 21,
1766
+ },
1767
+ });
1768
+
1769
+ expect(instance.style.numberOfLines).toBe(2);
1770
+ expect(instance.style.fontSize).toBe(14);
1771
+ expect(instance.style.lineHeight).toBe(21);
1772
+ });
1773
+
1774
+ it('seeds resolved lang from the locale snapshot', () => {
1775
+ setExactLocaleOverride({ locale: 'ar-EG', direction: 'rtl' });
1776
+
1777
+ const instance = createInstance('Text', {
1778
+ children: 'مرحبا',
1779
+ });
1780
+
1781
+ expect(instance.resolvedLang).toBe('ar-EG');
1782
+ expect(instance.resolvedDirection).toBe('rtl');
1783
+ });
1784
+
1785
+ it('re-resolves mounted trees when the locale snapshot changes', () => {
1786
+ const root = createRoot();
1787
+ const encoder = _getEncoder();
1788
+ const instance = createInstance('View', {});
1789
+
1790
+ appendChild(root, instance);
1791
+
1792
+ const setStyleSpy = vi.spyOn(encoder, 'setStyle');
1793
+ const setPropStringSpy = vi.spyOn(encoder, 'setPropString');
1794
+ setStyleSpy.mockClear();
1795
+ setPropStringSpy.mockClear();
1796
+
1797
+ expect(instance.resolvedLang).toBe('en-US');
1798
+ expect(instance.resolvedDirection).toBe('ltr');
1799
+
1800
+ setExactLocaleOverride({ locale: 'ar-EG', direction: 'rtl' });
1801
+
1802
+ expect(instance.resolvedLang).toBe('en-US');
1803
+ expect(instance.resolvedDirection).toBe('ltr');
1804
+
1805
+ vi.advanceTimersByTime(100);
1806
+
1807
+ expect(instance.resolvedLang).toBe('ar-EG');
1808
+ expect(instance.resolvedDirection).toBe('rtl');
1809
+ expect(setStyleSpy).toHaveBeenCalledWith(
1810
+ instance.id,
1811
+ expect.objectContaining({
1812
+ direction: 'rtl',
1813
+ }),
1814
+ );
1815
+ expect(setPropStringSpy).not.toHaveBeenCalledWith(instance.id, 17, 'ar-EG');
1816
+ });
1817
+
1818
+ it('does not encode inherited locale as a per-node lang prop', () => {
1819
+ setExactLocaleOverride({ locale: 'ar-EG', direction: 'rtl' });
1820
+ const root = createRoot();
1821
+ const encoder = _getEncoder();
1822
+ const instance = createInstance('Text', {
1823
+ children: 'مرحبا',
1824
+ });
1825
+
1826
+ encoder.reset();
1827
+ const setPropStringSpy = vi.spyOn(encoder, 'setPropString');
1828
+
1829
+ appendChild(root, instance);
1830
+ commitBatch(root);
1831
+
1832
+ expect(instance.resolvedLang).toBe('ar-EG');
1833
+ expect(setPropStringSpy).not.toHaveBeenCalledWith(instance.id, 17, 'ar-EG');
1834
+ });
1835
+
1836
+ it('auto-scrolls the nearest scroll view when a focused input is occluded by the keyboard', () => {
1837
+ const root = createRoot();
1838
+ const scroll = createInstance('ScrollView', {
1839
+ style: {
1840
+ width: 320,
1841
+ height: 400,
1842
+ },
1843
+ });
1844
+ const spacer = createInstance('View', {
1845
+ style: {
1846
+ height: 700,
1847
+ },
1848
+ });
1849
+ const input = createInstance('TextInput', {
1850
+ accessibilityLabel: 'Email',
1851
+ style: {
1852
+ height: 44,
1853
+ },
1854
+ });
1855
+
1856
+ nodeAppendChild(scroll, spacer);
1857
+ nodeAppendChild(scroll, input);
1858
+ appendChild(root, scroll);
1859
+ commitBatch(root);
1860
+
1861
+ dispatchSyntheticEvent(input, EventType.Focus, { nativeEvent: {} });
1862
+ globalThis.__exactKeyboardStateChanged?.({
1863
+ visible: true,
1864
+ mode: 'docked',
1865
+ progress: 1,
1866
+ occlusion: {
1867
+ x: 0,
1868
+ y: 500,
1869
+ width: 393,
1870
+ height: 352,
1871
+ },
1872
+ });
1873
+
1874
+ commitBatch(root);
1875
+
1876
+ expect(getScrollMetrics(scroll.id, root.rootId)?.currentOffset.y).toBeGreaterThan(0);
1877
+ });
1878
+
1879
+ it('does not dispatch a redundant root commit when only the live viewport changed', () => {
1880
+ const root = createRoot();
1881
+ const child = createInstance('View', {
1882
+ style: {
1883
+ flex: 1,
1884
+ },
1885
+ });
1886
+
1887
+ appendChild(root, child);
1888
+ commitBatch(root);
1889
+
1890
+ const encoder = _getEncoder();
1891
+ const setStyleSpy = vi.spyOn(encoder, 'setStyle');
1892
+ setStyleSpy.mockClear();
1893
+
1894
+ if (globalThis.exact) {
1895
+ globalThis.exact.screenWidth = 640;
1896
+ globalThis.exact.screenHeight = 480;
1897
+ }
1898
+
1899
+ syncRootInheritedWindowState(root);
1900
+
1901
+ expect(setStyleSpy).not.toHaveBeenCalled();
1902
+ });
1903
+ });
1904
+
1905
+ describe('RFC 0043 host-ops primitives', () => {
1906
+ it('detachChild preserves the subtree and its event handlers for reparenting', () => {
1907
+ const root = createRoot();
1908
+ const firstParent = createInstance('View', {});
1909
+ const secondParent = createInstance('View', {});
1910
+ const onPress = vi.fn();
1911
+ const child = createInstance('Pressable', {
1912
+ onPress,
1913
+ });
1914
+
1915
+ nodeAppendChild(firstParent, child);
1916
+ nodeAppendChild(root, firstParent);
1917
+ nodeAppendChild(root, secondParent);
1918
+ commitBatch(root);
1919
+
1920
+ detachChild(firstParent, child);
1921
+ appendChild(secondParent, child);
1922
+ commitBatch(root);
1923
+
1924
+ expect(child.parent).toBe(secondParent);
1925
+ expect(firstParent.children).not.toContain(child);
1926
+ expect(secondParent.children).toContain(child);
1927
+
1928
+ dispatchSyntheticEvent(child, EventType.Press, {
1929
+ nativeEvent: { timestamp: Date.now() },
1930
+ });
1931
+
1932
+ expect(onPress).toHaveBeenCalledTimes(1);
1933
+ });
1934
+
1935
+ it('pressOverride allows press bindings on tags that are not globally pressable', () => {
1936
+ const instance = createElementNode('view', 'div', false, {});
1937
+ const onPress = vi.fn();
1938
+ const config = getTagConfig('div');
1939
+
1940
+ expect(config).not.toBeNull();
1941
+
1942
+ processEventProps(instance, { onPress }, config!);
1943
+ expect(instance.events.has(EventType.Press)).toBe(false);
1944
+
1945
+ instance.pressOverride = true;
1946
+ processEventProps(instance, { onPress }, config!);
1947
+
1948
+ expect(instance.events.has(EventType.Press)).toBe(true);
1949
+ });
1950
+
1951
+ it('destroyRoot tears down the live root and releases its owner claim', () => {
1952
+ const root = createRoot(2, 'host-ops-test');
1953
+ const child = createInstance('View', {});
1954
+
1955
+ appendChild(root, child);
1956
+ commitBatch(root);
1957
+
1958
+ expect(getRootOwner(2)).toBe('host-ops-test');
1959
+
1960
+ destroyRoot(2);
1961
+
1962
+ expect(getRootOwner(2)).toBeUndefined();
1963
+ expect(getRootNode(2)).toBeNull();
1964
+ expect(globalThis.exact?.dispatch).toHaveBeenCalled();
1965
+ });
1966
+ });
1967
+
1968
+ describe('event prop rebinding (LLP 0159)', () => {
1969
+ it('rebinds the change closure when only onSelectionChange changes', () => {
1970
+ const onChangeText = vi.fn();
1971
+ const firstSelectionHandler = vi.fn();
1972
+ const secondSelectionHandler = vi.fn();
1973
+ const instance = createInstance('TextInput', {
1974
+ onChangeText,
1975
+ onSelectionChange: firstSelectionHandler,
1976
+ });
1977
+ processEventProps(instance, instance.originalProps, getTagConfig('TextInput')!);
1978
+
1979
+ const binding = instance.events.get(EventType.Change);
1980
+ expect(binding).toBeDefined();
1981
+
1982
+ // Only the selection handler changes; every other prop is identical.
1983
+ updateInstanceProps(instance, instance.originalProps, {
1984
+ onChangeText,
1985
+ onSelectionChange: secondSelectionHandler,
1986
+ });
1987
+
1988
+ const dispatch = (
1989
+ globalThis as {
1990
+ __exactDispatchEvent?: (handlerId: number, payload: unknown) => void;
1991
+ }
1992
+ ).__exactDispatchEvent;
1993
+
1994
+ expect(dispatch).toBeTypeOf('function');
1995
+
1996
+ dispatch!(binding!.handlerId, {
1997
+ nodeId: instance.id,
1998
+ text: 'Millbrae',
1999
+ selectionStart: 2,
2000
+ selectionEnd: 6,
2001
+ textChanged: false,
2002
+ });
2003
+
2004
+ expect(firstSelectionHandler).not.toHaveBeenCalled();
2005
+ expect(secondSelectionHandler).toHaveBeenCalledWith({
2006
+ nativeEvent: {
2007
+ selection: {
2008
+ start: 2,
2009
+ end: 6,
2010
+ },
2011
+ text: 'Millbrae',
2012
+ },
2013
+ });
2014
+ });
2015
+
2016
+ it('preserves the scroll throttle window when event props are rebuilt', () => {
2017
+ const onScroll = vi.fn();
2018
+ const instance = createInstance('ScrollView', {
2019
+ onScroll,
2020
+ scrollEventThrottle: 100,
2021
+ });
2022
+ processEventProps(instance, instance.originalProps, getTagConfig('ScrollView')!);
2023
+
2024
+ const dispatch = (
2025
+ globalThis as {
2026
+ __exactDispatchEvent?: (handlerId: number, payload: unknown) => void;
2027
+ }
2028
+ ).__exactDispatchEvent;
2029
+
2030
+ expect(dispatch).toBeTypeOf('function');
2031
+
2032
+ const binding = instance.events.get(EventType.Scroll);
2033
+ expect(binding).toBeDefined();
2034
+
2035
+ dispatch!(binding!.handlerId, {
2036
+ x: 0,
2037
+ y: 0,
2038
+ nativeEvent: { timestamp: 0 },
2039
+ });
2040
+ expect(onScroll).toHaveBeenCalledTimes(1);
2041
+
2042
+ // Rebuilding event props recreates the onScroll wrapper; the throttle
2043
+ // window must survive on the instance instead of resetting.
2044
+ processEventProps(
2045
+ instance,
2046
+ { onScroll, scrollEventThrottle: 100 },
2047
+ getTagConfig('ScrollView')!,
2048
+ );
2049
+ const rebound = instance.events.get(EventType.Scroll);
2050
+ expect(rebound).toBeDefined();
2051
+
2052
+ dispatch!(rebound!.handlerId, {
2053
+ x: 0,
2054
+ y: 50,
2055
+ nativeEvent: { timestamp: 50 },
2056
+ });
2057
+ expect(onScroll).toHaveBeenCalledTimes(1);
2058
+
2059
+ dispatch!(rebound!.handlerId, {
2060
+ x: 0,
2061
+ y: 120,
2062
+ nativeEvent: { timestamp: 120 },
2063
+ });
2064
+ expect(onScroll).toHaveBeenCalledTimes(2);
2065
+ });
2066
+ });
2067
+
2068
+ describe('updateInstanceProps fast path (LLP 0159)', () => {
2069
+ it('skips resolveElementState when a new props object is shallow-equal', () => {
2070
+ const root = createRoot();
2071
+ const style = { width: 100, height: 50 };
2072
+ const onPress = vi.fn();
2073
+ const instance = createInstance('Pressable', {
2074
+ style,
2075
+ onPress,
2076
+ testId: 'fast-path',
2077
+ });
2078
+
2079
+ appendChild(root, instance);
2080
+ commitBatch(root);
2081
+
2082
+ _resetResolveElementStateCallCount();
2083
+
2084
+ // New object identity, identical values (parent re-render).
2085
+ const recreatedProps = { style, onPress, testId: 'fast-path' };
2086
+ updateInstanceProps(instance, instance.originalProps, recreatedProps);
2087
+
2088
+ expect(_getResolveElementStateCallCount()).toBe(0);
2089
+ expect(instance.originalProps).toBe(recreatedProps);
2090
+
2091
+ // A real change still goes through full resolution.
2092
+ updateInstanceProps(instance, instance.originalProps, {
2093
+ style: { width: 120, height: 50 },
2094
+ onPress,
2095
+ testId: 'fast-path',
2096
+ });
2097
+ expect(_getResolveElementStateCallCount()).toBe(1);
2098
+ });
2099
+
2100
+ it('does not take the fast path when the same props object is mutated in place', () => {
2101
+ const root = createRoot();
2102
+ const instance = createInstance('View', {
2103
+ style: { width: 100, height: 50 },
2104
+ });
2105
+
2106
+ appendChild(root, instance);
2107
+ commitBatch(root);
2108
+
2109
+ _resetResolveElementStateCallCount();
2110
+
2111
+ // The DOM shim mutates its live props object and passes the same
2112
+ // reference; full re-resolution must run to discover changes.
2113
+ updateInstanceProps(instance, instance.originalProps, instance.originalProps);
2114
+
2115
+ expect(_getResolveElementStateCallCount()).toBe(1);
2116
+ });
2117
+ });
2118
+
2119
+ describe('fresh-mount prop encoding (LLP 0159)', () => {
2120
+ it('encodes selectable exactly once when mounting a fresh subtree', () => {
2121
+ const root = createRoot();
2122
+ const encoder = _getEncoder();
2123
+ const instance = createInstance('View', { selectable: true });
2124
+ const setPropStringSpy = vi.spyOn(encoder, 'setPropString');
2125
+
2126
+ appendChild(root, instance);
2127
+ commitBatch(root);
2128
+
2129
+ const selectableOps = setPropStringSpy.mock.calls.filter(
2130
+ ([nodeId, propId]) => nodeId === instance.id && propId === PropId.Selectable,
2131
+ );
2132
+ expect(selectableOps).toHaveLength(1);
2133
+ });
2134
+
2135
+ it('still re-encodes selectable when a committed node is reparented', () => {
2136
+ const root = createRoot();
2137
+ const encoder = _getEncoder();
2138
+ const container = createInstance('View', {});
2139
+ const item = createInstance('View', { selectable: true });
2140
+
2141
+ appendChild(root, container);
2142
+ appendChild(container, item);
2143
+ commitBatch(root);
2144
+
2145
+ const setPropStringSpy = vi.spyOn(encoder, 'setPropString');
2146
+
2147
+ appendChild(root, item);
2148
+ commitBatch(root);
2149
+
2150
+ const selectableOps = setPropStringSpy.mock.calls.filter(
2151
+ ([nodeId, propId]) => nodeId === item.id && propId === PropId.Selectable,
2152
+ );
2153
+ expect(selectableOps).toHaveLength(1);
2154
+ });
2155
+ });
2156
+
2157
+ describe('commit-time event registration (LLP 0159)', () => {
2158
+ it('keeps createInstance side-effect free and registers handlers at commit', () => {
2159
+ const root = createRoot();
2160
+ const onPress = vi.fn();
2161
+ const instance = createInstance('Pressable', { onPress });
2162
+
2163
+ // Render phase: no instance bindings and no global registry entries, so
2164
+ // an aborted render that drops the instance leaks nothing.
2165
+ expect(instance.events.size).toBe(0);
2166
+ expect(getHandler(1)).toBeUndefined();
2167
+
2168
+ appendChild(root, instance);
2169
+ commitBatch(root);
2170
+
2171
+ const binding = instance.events.get(EventType.Press);
2172
+ expect(binding).toBeDefined();
2173
+ expect(getHandler(binding!.handlerId)).toBeTypeOf('function');
2174
+
2175
+ dispatchSyntheticEvent(instance, EventType.Press, {
2176
+ nativeEvent: { timestamp: Date.now() },
2177
+ });
2178
+ expect(onPress).toHaveBeenCalledTimes(1);
2179
+ });
2180
+
2181
+ it('registers handlers for nested children of a freshly mounted subtree', () => {
2182
+ const root = createRoot();
2183
+ const onPress = vi.fn();
2184
+ const parent = createInstance('View', {});
2185
+ const child = createInstance('Pressable', { onPress });
2186
+
2187
+ nodeAppendChild(parent, child);
2188
+ expect(child.events.size).toBe(0);
2189
+
2190
+ appendChild(root, parent);
2191
+ commitBatch(root);
2192
+
2193
+ expect(child.events.get(EventType.Press)).toBeDefined();
2194
+ });
2195
+ });
2196
+
2197
+ describe('destroySubtree root attribution (LLP 0159)', () => {
2198
+ it('records destroyed view IDs under the owning root, not root 0', () => {
2199
+ const root0 = createRoot();
2200
+ const root2 = createRoot(2, 'attribution-test');
2201
+ const parent = createInstance('View', {});
2202
+ const child = createInstance('View', {});
2203
+ const grandchild = createInstance('View', {});
2204
+
2205
+ appendChild(root2, parent);
2206
+ appendChild(parent, child);
2207
+ appendChild(child, grandchild);
2208
+ commitBatch(root0);
2209
+ commitBatch(root2);
2210
+
2211
+ const renderEvents: number[][] = [];
2212
+ const unsubscribe = subscribeAgentEvents((event) => {
2213
+ if (event.type === 'render') {
2214
+ renderEvents.push([...event.changedViews]);
2215
+ }
2216
+ });
2217
+
2218
+ removeChild(parent, child);
2219
+ commitBatch(root0);
2220
+ commitBatch(root2);
2221
+ unsubscribe();
2222
+
2223
+ expect(renderEvents).toHaveLength(2);
2224
+ const [root0Changed, root2Changed] = renderEvents;
2225
+ // Root 0 saw no changes; before the rootId threading fix the destroyed
2226
+ // IDs of the severed subtree were misattributed here.
2227
+ expect(root0Changed).toEqual([]);
2228
+ expect(root2Changed).toEqual(
2229
+ expect.arrayContaining([parent.id, child.id, grandchild.id]),
2230
+ );
2231
+
2232
+ destroyRoot(2);
2233
+ });
2234
+ });