@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,682 @@
1
+ /// <reference path="../../../../js/src/types/bun-test.d.ts" />
2
+ // DOM mirror tests: host-ops trees render into real DOM on the plain-web
3
+ // path (no protocol host), with style conversion, event wiring back into
4
+ // handler props, keyed updates, and teardown.
5
+
6
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
7
+ import { Window } from 'happy-dom';
8
+
9
+ import {
10
+ appendChild,
11
+ commitBatch,
12
+ createInstance,
13
+ createRoot,
14
+ createTextInstance,
15
+ destroyRoot,
16
+ removeChild,
17
+ updateInstanceProps,
18
+ type ElementNode,
19
+ type RootNode,
20
+ type TextNode,
21
+ } from '../host-ops.js';
22
+ import {
23
+ installDomMirror,
24
+ type DomMirrorHandle,
25
+ } from '../dom-mirror.js';
26
+ import { installContractWebHost } from '../web-host.js';
27
+
28
+ const ROOT_ID = 7341;
29
+
30
+ let windowRef: Window;
31
+ let mirror: DomMirrorHandle | null = null;
32
+ let root: RootNode;
33
+
34
+ beforeEach(() => {
35
+ windowRef = new Window();
36
+ (globalThis as Record<string, unknown>).document = windowRef.document;
37
+ (globalThis as Record<string, unknown>).window = windowRef;
38
+ mirror = installDomMirror({
39
+ container: windowRef.document.body as unknown as HTMLElement,
40
+ });
41
+ root = createRoot(ROOT_ID, 'contract');
42
+ });
43
+
44
+ afterEach(() => {
45
+ destroyRoot(ROOT_ID);
46
+ mirror?.dispose();
47
+ mirror = null;
48
+ delete (globalThis as Record<string, unknown>).document;
49
+ delete (globalThis as Record<string, unknown>).window;
50
+ });
51
+
52
+ function byAttr(scope: { getElementsByTagName(tag: string): unknown }, attr: string, value: string): HTMLElement | null {
53
+ const all = scope.getElementsByTagName('*') as unknown as HTMLElement[];
54
+ return ([...all].find((el) => el.getAttribute(attr) === value) ?? null) as HTMLElement | null;
55
+ }
56
+
57
+ function mirrorRoot(): HTMLElement {
58
+ return byAttr(windowRef.document as unknown as { getElementsByTagName(tag: string): unknown }, 'data-exact-mirror-root', String(ROOT_ID))!;
59
+ }
60
+
61
+ function byTestId(testId: string): HTMLElement | null {
62
+ return byAttr(mirrorRoot() as unknown as { getElementsByTagName(tag: string): unknown }, 'data-testid', testId);
63
+ }
64
+
65
+ describe('dom mirror', () => {
66
+ it('renders elements, text, styles, and aria attributes into real DOM', () => {
67
+ const view = createInstance('View', {
68
+ testId: 'card',
69
+ accessibilityRole: 'group',
70
+ style: {
71
+ flexDirection: 'row',
72
+ padding: 12,
73
+ gap: 8,
74
+ backgroundColor: '#fffaf0',
75
+ borderRadius: 12,
76
+ opacity: 0.9,
77
+ transform: [{ translateX: 4 }, { scale: 0.98 }],
78
+ fontFamily: ['ui-sans-serif', 'sans-serif'],
79
+ },
80
+ });
81
+ const label = createInstance('Text', { style: { fontSize: 14, fontWeight: 600 } });
82
+ appendChild(label, createTextInstance('Hello pixels'));
83
+ appendChild(view, label);
84
+ appendChild(root, view);
85
+ commitBatch(root);
86
+
87
+ const card = byTestId('card')!;
88
+ expect(card).toBeTruthy();
89
+ expect(card.getAttribute('role')).toBe('group');
90
+ expect(card.style.flexDirection).toBe('row');
91
+ expect(card.style.padding).toBe('12px');
92
+ expect(card.style.gap).toBe('8px');
93
+ expect(card.style.borderRadius).toBe('12px');
94
+ expect(card.style.opacity).toBe('0.9');
95
+ expect(card.style.transform).toBe('translateX(4px) scale(0.98)');
96
+ expect(card.textContent).toBe('Hello pixels');
97
+ const span = card.getElementsByTagName('span')[0] as unknown as HTMLElement;
98
+ expect(span.style.fontSize).toBe('14px');
99
+ });
100
+
101
+ it('defaults RN container tags to flex-shrink:0 to match the kernel (ENG-22199)', () => {
102
+ // CSS flexbox defaults flex-shrink to 1; RN/Yoga and the kernel/Taffy path
103
+ // default it to 0. Without this, a bare row/column inside an overflowing
104
+ // flex column shrinks below its content (to the min-height:0 set here) and
105
+ // collapses to height 0 while its children paint full-size — the bug behind
106
+ // ENG-22199. The mirror must match native and reserve content size.
107
+ const bare = createInstance('View', { testId: 'bare', style: { flexDirection: 'row' } });
108
+ const explicitShrink = createInstance('View', {
109
+ testId: 'shrinky',
110
+ style: { flexShrink: 1 },
111
+ });
112
+ const explicitFlex = createInstance('View', { testId: 'flexed', style: { flex: 1 } });
113
+ const scroller = createInstance('ScrollView', { testId: 'scroller' });
114
+ appendChild(root, bare);
115
+ appendChild(root, explicitShrink);
116
+ appendChild(root, explicitFlex);
117
+ appendChild(root, scroller);
118
+ commitBatch(root);
119
+
120
+ // Default: no shrink, but still min-height:0 so scroll/ellipsis children work.
121
+ expect(byTestId('bare')!.style.flexShrink).toBe('0');
122
+ expect(byTestId('bare')!.style.minHeight).toBe('0');
123
+ // Explicit shrink/flex win over the default (applied after tag defaults).
124
+ expect(byTestId('shrinky')!.style.flexShrink).toBe('1');
125
+ // `flex: 1` is the shorthand for `1 1 0%`, so it resets shrink back to 1.
126
+ expect(byTestId('flexed')!.style.flexShrink).toBe('1');
127
+ // ScrollView wants to fill and scroll — its flex:1 1 0% keeps it shrinkable.
128
+ expect(byTestId('scroller')!.style.flex).toBe('1 1 0%');
129
+ expect(byTestId('scroller')!.style.flexShrink).toBe('1');
130
+ });
131
+
132
+ it('applies line-clamp styles from top-level numberOfLines', () => {
133
+ const text = createInstance('Text', {
134
+ numberOfLines: 2,
135
+ style: {
136
+ fontSize: 14,
137
+ lineHeight: 21,
138
+ },
139
+ });
140
+ appendChild(text, createTextInstance('Long paragraph that should clamp after two lines.'));
141
+ expect(text.style.numberOfLines).toBe(2);
142
+ appendChild(root, text);
143
+ commitBatch(root);
144
+
145
+ const span = mirrorRoot().getElementsByTagName('span')[0] as HTMLElement;
146
+ expect(span.style.fontSize).toBe('14px');
147
+ // happy-dom does not round-trip `display: -webkit-box`; real browsers do.
148
+ expect(span.style.getPropertyValue('-webkit-line-clamp')).toBe('2');
149
+ expect(span.style.getPropertyValue('overflow')).toBe('hidden');
150
+ });
151
+
152
+ it('renders inline svgSource images as tintable inline SVG hosts', () => {
153
+ const icon = createInstance('img', {
154
+ testId: 'mode-icon',
155
+ svgSource: '<svg viewBox="0 0 24 24"><path fill="#000" d="M4 4h16v16H4z"/></svg>',
156
+ alt: 'System',
157
+ tintColor: '#123456',
158
+ style: { width: 14, height: 14 },
159
+ });
160
+ appendChild(root, icon);
161
+ commitBatch(root);
162
+
163
+ const host = byTestId('mode-icon')!;
164
+ const svg = host.getElementsByTagName('svg')[0] as unknown as SVGSVGElement;
165
+ const path = svg.querySelector('path')!;
166
+ const title = svg.querySelector('title')!;
167
+
168
+ expect(host.tagName).toBe('SPAN');
169
+ expect(host.hasAttribute('alt')).toBe(false);
170
+ expect(host.hasAttribute('data-exact-inline-svg-source')).toBe(false);
171
+ expect(host.style.width).toBe('14px');
172
+ expect(host.style.height).toBe('14px');
173
+ expect(svg.getAttribute('role')).toBe('img');
174
+ expect(svg.getAttribute('aria-labelledby')).toBe(title.getAttribute('id'));
175
+ expect(svg.style.color).toBe('#123456');
176
+ expect(path.getAttribute('fill')).toBe('currentColor');
177
+ expect(title.textContent).toBe('System');
178
+ });
179
+
180
+ it('renders theme-aware image source metadata and selects the active scheme source', () => {
181
+ windowRef.document.documentElement.setAttribute('data-blog-appearance', 'dark');
182
+ const image = createInstance('img', {
183
+ testId: 'graph',
184
+ src: '/assets/graph.light.png',
185
+ lightSrc: '/assets/graph.light.png',
186
+ darkSrc: '/assets/graph.dark.png',
187
+ alt: 'Build quality over time',
188
+ });
189
+ appendChild(root, image);
190
+ commitBatch(root);
191
+
192
+ const img = byTestId('graph') as HTMLImageElement;
193
+ expect(img.tagName).toBe('IMG');
194
+ expect(img.getAttribute('src')).toBe('/assets/graph.dark.png');
195
+ expect(img.getAttribute('data-exact-theme-image')).toBe('color-scheme');
196
+ expect(img.getAttribute('data-light-src')).toBe('/assets/graph.light.png');
197
+ expect(img.getAttribute('data-dark-src')).toBe('/assets/graph.dark.png');
198
+ expect(img.getAttribute('alt')).toBe('Build quality over time');
199
+ });
200
+
201
+ it('installs a production Contract web host that consumes shared semantic attrs', () => {
202
+ mirror?.dispose();
203
+ destroyRoot(ROOT_ID);
204
+ mirror = installContractWebHost({
205
+ container: windowRef.document.body as unknown as HTMLElement,
206
+ });
207
+ root = createRoot(ROOT_ID, 'contract');
208
+
209
+ const region = createInstance('View', {
210
+ __exactSemanticTag: 'section',
211
+ testId: 'region',
212
+ accessibilityRole: 'region',
213
+ accessibilityLabel: 'Stations',
214
+ inert: true,
215
+ focusable: true,
216
+ style: { padding: 8 },
217
+ });
218
+ const heading = createInstance('Text', {
219
+ testId: 'heading',
220
+ accessibilityRole: 'heading',
221
+ accessibilityHeadingLevel: 2,
222
+ });
223
+ appendChild(heading, createTextInstance('Stations'));
224
+ const nav = createInstance('View', {
225
+ testId: 'nav',
226
+ accessibilityRole: 'navigation',
227
+ accessibilityLabel: 'Primary navigation',
228
+ });
229
+ const routeLink = createInstance('Pressable', {
230
+ __exactSemanticTag: 'a',
231
+ testId: 'route-home-link',
232
+ accessibilityRole: 'link',
233
+ accessibilityLabel: 'Labs',
234
+ href: '/',
235
+ });
236
+ appendChild(routeLink, createTextInstance('Labs'));
237
+ appendChild(nav, routeLink);
238
+ const label = createInstance('Text', {
239
+ __exactSemanticTag: 'label',
240
+ testId: 'email-label',
241
+ nativeID: 'email-label',
242
+ accessibilityFor: 'email',
243
+ });
244
+ appendChild(label, createTextInstance('Email'));
245
+ const input = createInstance('TextInput', {
246
+ testId: 'email',
247
+ nativeID: 'email',
248
+ accessibilityLabelledBy: 'email-label',
249
+ value: 'ada@example.com',
250
+ });
251
+ const list = createInstance('List', {
252
+ testId: 'station-list',
253
+ accessibilityRole: 'list',
254
+ });
255
+ const item = createInstance('View', {
256
+ testId: 'station-row',
257
+ accessibilityRole: 'listitem',
258
+ });
259
+ appendChild(item, createTextInstance('Mountain View'));
260
+ appendChild(list, item);
261
+ appendChild(region, heading);
262
+ appendChild(region, nav);
263
+ appendChild(region, label);
264
+ appendChild(region, input);
265
+ appendChild(region, list);
266
+ appendChild(root, region);
267
+ commitBatch(root);
268
+
269
+ const hostRoot = byAttr(
270
+ windowRef.document as unknown as { getElementsByTagName(tag: string): unknown },
271
+ 'data-exact-contract-host-root',
272
+ String(ROOT_ID),
273
+ )!;
274
+ expect(hostRoot).toBeTruthy();
275
+ expect(
276
+ byAttr(
277
+ windowRef.document as unknown as { getElementsByTagName(tag: string): unknown },
278
+ 'data-exact-mirror-root',
279
+ String(ROOT_ID),
280
+ ),
281
+ ).toBeNull();
282
+
283
+ const regionEl = byAttr(hostRoot as unknown as { getElementsByTagName(tag: string): unknown }, 'data-testid', 'region')!;
284
+ const headingEl = byAttr(hostRoot as unknown as { getElementsByTagName(tag: string): unknown }, 'data-testid', 'heading')!;
285
+ const navEl = byAttr(hostRoot as unknown as { getElementsByTagName(tag: string): unknown }, 'data-testid', 'nav')!;
286
+ const linkEl = byAttr(hostRoot as unknown as { getElementsByTagName(tag: string): unknown }, 'data-testid', 'route-home-link')!;
287
+ const labelEl = byAttr(hostRoot as unknown as { getElementsByTagName(tag: string): unknown }, 'data-testid', 'email-label')!;
288
+ const inputEl = byAttr(hostRoot as unknown as { getElementsByTagName(tag: string): unknown }, 'data-testid', 'email')! as HTMLInputElement;
289
+ const listEl = byAttr(hostRoot as unknown as { getElementsByTagName(tag: string): unknown }, 'data-testid', 'station-list')!;
290
+ const itemEl = byAttr(hostRoot as unknown as { getElementsByTagName(tag: string): unknown }, 'data-testid', 'station-row')!;
291
+
292
+ expect(regionEl.tagName).toBe('SECTION');
293
+ expect(regionEl.getAttribute('role')).toBe('region');
294
+ expect(regionEl.getAttribute('aria-label')).toBe('Stations');
295
+ expect(regionEl.hasAttribute('inert')).toBe(true);
296
+ expect(regionEl.getAttribute('aria-hidden')).toBe('true');
297
+ expect(regionEl.tabIndex).toBe(0);
298
+ expect(regionEl.hasAttribute('data-exact-view-id')).toBe(false);
299
+ expect(globalThis.__exactDomIdentity?.elementForViewId(region.id)).toBe(regionEl);
300
+ expect(globalThis.__exactDomIdentity?.viewIdForTarget(regionEl)).toBe(region.id);
301
+ expect(headingEl.tagName).toBe('H2');
302
+ expect(headingEl.getAttribute('role')).toBeNull();
303
+ // Headings must not leak the user-agent stylesheet's `margin-block` into the
304
+ // cross-platform layout — native (no UA sheet) has none, so web layout has to
305
+ // match (ENG-22092). Surfaces add explicit heading spacing when they want it.
306
+ expect((headingEl as unknown as HTMLElement).style.marginTop).toBe('0px');
307
+ expect((headingEl as unknown as HTMLElement).style.marginBottom).toBe('0px');
308
+ expect(navEl.tagName).toBe('NAV');
309
+ expect(navEl.getAttribute('role')).toBeNull();
310
+ expect(linkEl.tagName).toBe('A');
311
+ expect(linkEl.getAttribute('role')).toBeNull();
312
+ expect(linkEl.getAttribute('href')).toBe('/');
313
+ expect(linkEl.getAttribute('aria-label')).toBe('Labs');
314
+ expect(linkEl.textContent).toBe('Labs');
315
+ expect(labelEl.tagName).toBe('LABEL');
316
+ expect(labelEl.getAttribute('for')).toBe('email');
317
+ expect(inputEl.tagName).toBe('INPUT');
318
+ expect(inputEl.getAttribute('id')).toBe('email');
319
+ expect(inputEl.getAttribute('aria-labelledby')).toBe('email-label');
320
+ expect(inputEl.value).toBe('ada@example.com');
321
+ expect(listEl.tagName).toBe('UL');
322
+ expect(itemEl.tagName).toBe('LI');
323
+ });
324
+
325
+ it('renders an inline link inside a paragraph inline, not as a block (ENG-22115)', () => {
326
+ // A markdown paragraph: text "It's at " + link + text ". Take it...". The
327
+ // link is a Pressable (semanticTag 'a') child of the paragraph's Text. On
328
+ // web it must flow inline; previously the <a> tag default forced
329
+ // display:flex/column, breaking the link onto its own line.
330
+ const paragraph = createInstance('Text', {
331
+ testId: 'para',
332
+ style: { fontSize: 16, lineHeight: 28 },
333
+ });
334
+ appendChild(paragraph, (() => {
335
+ const lead = createInstance('Text', {});
336
+ appendChild(lead, createTextInstance("It's at "));
337
+ return lead;
338
+ })());
339
+ const link = createInstance('Pressable', {
340
+ __exactSemanticTag: 'a',
341
+ testId: 'inline-link',
342
+ accessibilityRole: 'link',
343
+ href: 'https://github.com/ccheever/llp',
344
+ });
345
+ appendChild(link, (() => {
346
+ const linkText = createInstance('Text', { style: { textDecorationLine: 'underline' } });
347
+ appendChild(linkText, createTextInstance('github.com/ccheever/llp'));
348
+ return linkText;
349
+ })());
350
+ appendChild(paragraph, link);
351
+ appendChild(paragraph, (() => {
352
+ const trail = createInstance('Text', {});
353
+ appendChild(trail, createTextInstance('. Take it, use it.'));
354
+ return trail;
355
+ })());
356
+ appendChild(root, paragraph);
357
+ commitBatch(root);
358
+
359
+ const linkEl = byTestId('inline-link')!;
360
+ expect(linkEl.tagName).toBe('A');
361
+ expect(linkEl.style.display).toBe('inline');
362
+ expect(linkEl.style.flexDirection).toBe('');
363
+ });
364
+
365
+ it('keeps a standalone link outside text as a block-level flex container (ENG-22115)', () => {
366
+ // A link whose parent is a View (e.g. a footnote back-link inside a row),
367
+ // not a Text, must keep its flex layout — the inline override is scoped to
368
+ // links that participate in an inline text run.
369
+ const row = createInstance('View', { testId: 'row', style: { flexDirection: 'row' } });
370
+ const link = createInstance('Pressable', {
371
+ __exactSemanticTag: 'a',
372
+ testId: 'standalone-link',
373
+ accessibilityRole: 'link',
374
+ href: '#fn-1',
375
+ });
376
+ appendChild(link, createTextInstance('1.'));
377
+ appendChild(row, link);
378
+ appendChild(root, row);
379
+ commitBatch(root);
380
+
381
+ const linkEl = byTestId('standalone-link')!;
382
+ expect(linkEl.tagName).toBe('A');
383
+ expect(linkEl.style.display).toBe('flex');
384
+ });
385
+
386
+ it('focuses trapped scopes, keeps Tab inside, and restores focus after dismiss', () => {
387
+ const trigger = createInstance('Pressable', {
388
+ testId: 'open-dialog',
389
+ accessibilityLabel: 'Open dialog',
390
+ });
391
+ appendChild(trigger, createTextInstance('Open dialog'));
392
+ appendChild(root, trigger);
393
+ commitBatch(root);
394
+
395
+ const triggerEl = byTestId('open-dialog')!;
396
+ triggerEl.focus();
397
+ expect(windowRef.document.activeElement).toBe(triggerEl);
398
+
399
+ let dialog: ElementNode | null = null;
400
+ dialog = createInstance('View', {
401
+ testId: 'gallery-dialog',
402
+ accessibilityRole: 'dialog',
403
+ accessibilityModal: true,
404
+ focusScope: 'trapped',
405
+ __exactFocusRestore: true,
406
+ onKeyDown(payload: { key?: string }) {
407
+ if (payload.key === 'Escape' && dialog) {
408
+ removeChild(root, dialog);
409
+ dialog = null;
410
+ commitBatch(root);
411
+ }
412
+ },
413
+ });
414
+ appendChild(dialog, createTextInstance('Dialog body'));
415
+ appendChild(root, dialog);
416
+ commitBatch(root);
417
+
418
+ const dialogEl = byTestId('gallery-dialog')!;
419
+ expect(dialogEl).toBeTruthy();
420
+ expect(dialogEl.getAttribute('role')).toBe('dialog');
421
+ expect(dialogEl.getAttribute('aria-modal')).toBe('true');
422
+ expect(dialogEl.tabIndex).toBe(-1);
423
+ expect(windowRef.document.activeElement).toBe(dialogEl);
424
+
425
+ const tabEvent = new windowRef.KeyboardEvent('keydown', {
426
+ key: 'Tab',
427
+ bubbles: true,
428
+ cancelable: true,
429
+ });
430
+ dialogEl.dispatchEvent(tabEvent);
431
+ expect(tabEvent.defaultPrevented).toBe(true);
432
+ expect(windowRef.document.activeElement).toBe(dialogEl);
433
+
434
+ dialogEl.dispatchEvent(
435
+ new windowRef.KeyboardEvent('keydown', {
436
+ key: 'Escape',
437
+ bubbles: true,
438
+ cancelable: true,
439
+ }),
440
+ );
441
+
442
+ expect(byTestId('gallery-dialog')).toBeNull();
443
+ expect(windowRef.document.activeElement).toBe(triggerEl);
444
+ });
445
+
446
+ it('adopts pre-rendered Contract DOM from hydration anchors', () => {
447
+ mirror?.dispose();
448
+ destroyRoot(ROOT_ID);
449
+ windowRef.document.body.innerHTML =
450
+ `<div id="route">` +
451
+ `<div data-exact-contract-host-root="${ROOT_ID}">` +
452
+ `<main data-exact-hydration-id="App/v" data-exact-structural-id="App/v">Hello</main>` +
453
+ `</div>` +
454
+ `</div>`;
455
+ const route = windowRef.document.getElementById('route') as HTMLElement;
456
+ const existingMain = route.querySelector('main')!;
457
+ let result: { adoptedNodes: number; recreatedNodes: number; mismatches: string[] } | null = null;
458
+ mirror = installContractWebHost({
459
+ container: route,
460
+ hydrate: {
461
+ rootId: ROOT_ID,
462
+ manifest: {
463
+ version: 1,
464
+ rootId: ROOT_ID,
465
+ nodes: [
466
+ { nodeId: 'App/v', domPath: [0], kind: 'element', tag: 'main' },
467
+ { nodeId: 'App/v0', domPath: [0, 0], kind: 'text', textGuard: 'Hello' },
468
+ ],
469
+ },
470
+ onResult(next) {
471
+ result = next;
472
+ },
473
+ },
474
+ });
475
+ root = createRoot(ROOT_ID, 'contract');
476
+
477
+ const main = createInstance('View', {
478
+ __exactSemanticTag: 'main',
479
+ 'data-exact-hydration-id': 'App/v',
480
+ 'data-exact-structural-id': 'App/v',
481
+ });
482
+ const text = createTextInstance('Hello') as TextNode & { __exactHydrationId?: string };
483
+ text.__exactHydrationId = 'App/v0';
484
+ appendChild(main, text);
485
+ appendChild(root, main);
486
+ commitBatch(root);
487
+
488
+ expect(route.querySelector('main')).toBe(existingMain);
489
+ expect(result?.adoptedNodes).toBe(2);
490
+ expect(result?.recreatedNodes).toBe(0);
491
+ expect(result?.mismatches).toEqual([]);
492
+ });
493
+
494
+ it('routes DOM events to handler props, including hover and press', () => {
495
+ const events: string[] = [];
496
+ const button = createInstance('Pressable', {
497
+ testId: 'press-me',
498
+ accessibilityRole: 'button',
499
+ onPress: () => events.push('press'),
500
+ onPressIn: () => events.push('press-in'),
501
+ onPressOut: () => events.push('press-out'),
502
+ onHoverIn: () => events.push('hover-in'),
503
+ onHoverOut: () => events.push('hover-out'),
504
+ });
505
+ appendChild(button, createTextInstance('Press me'));
506
+ appendChild(root, button);
507
+ commitBatch(root);
508
+
509
+ const dom = byTestId('press-me')!;
510
+ expect(dom.tagName).toBe('BUTTON');
511
+
512
+ dom.dispatchEvent(new (windowRef.window as unknown as { PointerEvent: typeof PointerEvent }).PointerEvent('pointerenter'));
513
+ dom.dispatchEvent(new (windowRef.window as unknown as { PointerEvent: typeof PointerEvent }).PointerEvent('pointerdown'));
514
+ dom.dispatchEvent(new (windowRef.window as unknown as { PointerEvent: typeof PointerEvent }).PointerEvent('pointerup'));
515
+ dom.click();
516
+ dom.dispatchEvent(new (windowRef.window as unknown as { PointerEvent: typeof PointerEvent }).PointerEvent('pointerleave'));
517
+
518
+ expect(events).toEqual(['hover-in', 'press-in', 'press-out', 'press', 'press-out', 'hover-out']);
519
+ });
520
+
521
+ it('applies prop updates and removals across commits', () => {
522
+ const view = createInstance('View', {
523
+ testId: 'box',
524
+ style: { backgroundColor: 'red', width: 100 },
525
+ });
526
+ appendChild(root, view);
527
+ commitBatch(root);
528
+
529
+ const dom = () => byTestId('box')!;
530
+ expect(dom().style.backgroundColor).toBe('red');
531
+ expect(dom().style.width).toBe('100px');
532
+
533
+ updateInstanceProps(view, view.originalProps ?? {}, {
534
+ testId: 'box',
535
+ style: { backgroundColor: 'blue', height: 50 },
536
+ });
537
+ commitBatch(root);
538
+
539
+ expect(dom().style.backgroundColor).toBe('blue');
540
+ expect(dom().style.height).toBe('50px');
541
+ // The replaced style object dropped width; the mirror must too.
542
+ expect(dom().style.width).toBe('');
543
+
544
+ removeChild(root as unknown as ElementNode, view);
545
+ commitBatch(root);
546
+ expect(byTestId('box')).toBeNull();
547
+ });
548
+
549
+ it('syncs controlled inputs both ways', () => {
550
+ const seen: string[] = [];
551
+ const input = createInstance('TextInput', {
552
+ testId: 'field',
553
+ value: 'start',
554
+ placeholder: 'Type here',
555
+ onChangeText: (text: string) => seen.push(text),
556
+ });
557
+ appendChild(root, input);
558
+ commitBatch(root);
559
+
560
+ const dom = byTestId('field')! as unknown as HTMLInputElement;
561
+ expect(dom.tagName).toBe('INPUT');
562
+ expect(dom.value).toBe('start');
563
+ expect(dom.getAttribute('placeholder')).toBe('Type here');
564
+
565
+ dom.value = 'typed';
566
+ dom.dispatchEvent(new (windowRef.window as unknown as { Event: typeof Event }).Event('input'));
567
+ expect(seen).toEqual(['typed']);
568
+ expect(dom.value).toBe('start');
569
+ });
570
+
571
+ it('keeps a controlled DOM input aligned with synchronous accept and reject handlers', () => {
572
+ const input = createInstance('TextInput', {
573
+ testId: 'controlled-field',
574
+ value: '123',
575
+ onChangeText: (text: string) => {
576
+ if (/^[0-9]*$/.test(text)) {
577
+ updateInstanceProps(input, input.originalProps ?? {}, {
578
+ testId: 'controlled-field',
579
+ value: text,
580
+ onChangeText: (input.originalProps as Record<string, unknown>).onChangeText,
581
+ });
582
+ commitBatch(root);
583
+ }
584
+ },
585
+ });
586
+ appendChild(root, input);
587
+ commitBatch(root);
588
+
589
+ const dom = byTestId('controlled-field')! as unknown as HTMLInputElement;
590
+ const inputEvent = () =>
591
+ new (windowRef.window as unknown as { Event: typeof Event }).Event('input');
592
+
593
+ dom.value = '1234';
594
+ dom.dispatchEvent(inputEvent());
595
+ expect(dom.value).toBe('1234');
596
+
597
+ dom.value = '1234a';
598
+ dom.dispatchEvent(inputEvent());
599
+ expect(dom.value).toBe('1234');
600
+ });
601
+
602
+ it('does not install when a protocol host owns pixels', () => {
603
+ (globalThis as Record<string, unknown>).exact = { dispatch: () => {} };
604
+ try {
605
+ const handle = installDomMirror({
606
+ container: windowRef.document.body as unknown as HTMLElement,
607
+ });
608
+ expect(handle).toBeNull();
609
+ } finally {
610
+ delete (globalThis as Record<string, unknown>).exact;
611
+ // beforeEach's mirror was replaced in the registry by nothing; restore
612
+ // for afterEach symmetry (dispose handles the rest).
613
+ mirror?.dispose();
614
+ mirror = installDomMirror({
615
+ container: windowRef.document.body as unknown as HTMLElement,
616
+ });
617
+ }
618
+ });
619
+
620
+ it('removes the root container on destroyRoot', () => {
621
+ const view = createInstance('View', { testId: 'temp' });
622
+ appendChild(root, view);
623
+ commitBatch(root);
624
+ expect(mirrorRoot()).toBeTruthy();
625
+
626
+ destroyRoot(ROOT_ID);
627
+ expect(
628
+ byAttr(windowRef.document as unknown as { getElementsByTagName(tag: string): unknown }, 'data-exact-mirror-root', String(ROOT_ID)),
629
+ ).toBeNull();
630
+
631
+ // afterEach destroys again; that is a harmless no-op path.
632
+ root = createRoot(ROOT_ID, 'contract');
633
+ });
634
+
635
+ it('keeps root ScrollView viewport-sized so tall content scrolls inside it', () => {
636
+ const host = windowRef.document.createElement('div') as unknown as HTMLElement;
637
+ const hostStyle = host.style as unknown as Record<string, string>;
638
+ hostStyle.display = 'flex';
639
+ hostStyle.flexDirection = 'column';
640
+ hostStyle.width = '100%';
641
+ hostStyle.height = '480px';
642
+ hostStyle.overflow = 'hidden';
643
+ windowRef.document.body.appendChild(host);
644
+
645
+ mirror?.dispose();
646
+ destroyRoot(ROOT_ID);
647
+ mirror = installDomMirror({ container: host });
648
+ root = createRoot(ROOT_ID, 'contract');
649
+
650
+ const scroll = createInstance('ScrollView', {
651
+ testId: 'labs-home',
652
+ style: {
653
+ flex: 1,
654
+ width: '100%',
655
+ backgroundColor: '#050607',
656
+ },
657
+ });
658
+ const content = createInstance('View', {
659
+ style: {
660
+ width: '100%',
661
+ maxWidth: 920,
662
+ alignSelf: 'center',
663
+ height: 1200,
664
+ backgroundColor: '#101010',
665
+ },
666
+ });
667
+ appendChild(scroll, content);
668
+ appendChild(root, scroll);
669
+ commitBatch(root);
670
+
671
+ const scrollEl = byTestId('labs-home')!;
672
+ const contentEl = scrollEl.firstElementChild as HTMLElement;
673
+ expect(scrollEl.style.width).toBe('100%');
674
+ expect(scrollEl.style.overflowY).toBe('auto');
675
+ expect(scrollEl.style.minHeight).toBe('0');
676
+ expect(scrollEl.style.flex).toBe('1 1 0%');
677
+ expect(mirrorRoot().style.height).toBe('100%');
678
+ expect(contentEl.style.height).toBe('1200px');
679
+
680
+ host.parentNode?.removeChild(host);
681
+ });
682
+ });