@ng-native/fabric 0.0.1 → 0.1.1

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -1
  3. package/dist/color-mix.d.ts +20 -0
  4. package/dist/color-mix.js +306 -0
  5. package/dist/color-mix.js.map +1 -0
  6. package/dist/css.d.ts +615 -0
  7. package/dist/css.js +1405 -0
  8. package/dist/css.js.map +1 -0
  9. package/dist/defer-triggers.d.ts +53 -0
  10. package/dist/defer-triggers.js +184 -0
  11. package/dist/defer-triggers.js.map +1 -0
  12. package/dist/engine.d.ts +1066 -0
  13. package/dist/engine.js +2764 -0
  14. package/dist/engine.js.map +1 -0
  15. package/dist/fabric.d.ts +20 -0
  16. package/dist/fabric.js +44 -0
  17. package/dist/fabric.js.map +1 -0
  18. package/dist/forms-inputs.d.ts +15 -0
  19. package/dist/forms-inputs.js +54 -0
  20. package/dist/forms-inputs.js.map +1 -0
  21. package/dist/host.d.ts +133 -0
  22. package/dist/host.js +55 -0
  23. package/dist/host.js.map +1 -0
  24. package/dist/index.d.ts +20 -0
  25. package/dist/index.js +21 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/inline-token.d.ts +12 -0
  28. package/dist/inline-token.js +52 -0
  29. package/dist/inline-token.js.map +1 -0
  30. package/dist/inline-transform.d.ts +14 -0
  31. package/dist/inline-transform.js +41 -0
  32. package/dist/inline-transform.js.map +1 -0
  33. package/dist/native-drive.d.ts +100 -0
  34. package/dist/native-drive.js +318 -0
  35. package/dist/native-drive.js.map +1 -0
  36. package/dist/relative-colour.d.ts +17 -0
  37. package/dist/relative-colour.js +71 -0
  38. package/dist/relative-colour.js.map +1 -0
  39. package/dist/scroll-animation.d.ts +41 -0
  40. package/dist/scroll-animation.js +169 -0
  41. package/dist/scroll-animation.js.map +1 -0
  42. package/dist/transition.d.ts +139 -0
  43. package/dist/transition.js +569 -0
  44. package/dist/transition.js.map +1 -0
  45. package/package.json +43 -3
package/dist/engine.js ADDED
@@ -0,0 +1,2764 @@
1
+ /**
2
+ * Framework-agnostic retained tree + incremental Fabric commit.
3
+ * Nothing Angular-specific may enter this file; lint bans the import (see ARCHITECTURE.md).
4
+ *
5
+ * Angular mutates; Fabric is persistent. A committed node is never mutated, it is cloned with
6
+ * new props and/or a new child set. Cloning a leaf therefore forces every ancestor to re-clone,
7
+ * because a persistent parent holds references to specific child handles. That bubble is
8
+ * inherent and is what React's own Fabric renderer does.
9
+ */
10
+ import { StyleResolver, } from "./css.js";
11
+ import { animationEvent, bezier, interpolate, sample, step, tracksOf, } from "./transition.js";
12
+ import { isFormsInput } from "./forms-inputs.js";
13
+ import { transformList } from "./inline-transform.js";
14
+ import { tokenFromValue } from "./inline-token.js";
15
+ import { NativeScrollDriver, scrollFeed, } from "./native-drive.js";
16
+ import { firstFrame, rangeOf, scrollChannels } from "./scroll-animation.js";
17
+ /**
18
+ * The animation a node's style asks for, with a play state from a rule of its own applied. Both
19
+ * are instructions for this engine and nothing native has heard of them, so they come out of the
20
+ * props; only when there, because a `delete` sends an object to Hermes's slow dictionary layout,
21
+ * and this runs for every node that commits.
22
+ */
23
+ function animationOf(props) {
24
+ const spec = props['$animation'];
25
+ if (spec !== undefined)
26
+ delete props['$animation'];
27
+ const playState = props['$playState'];
28
+ if (playState === undefined)
29
+ return spec;
30
+ delete props['$playState'];
31
+ return spec && { ...spec, paused: playState === 'paused' };
32
+ }
33
+ /**
34
+ * Whether two specs ask for the same animation. By what they say rather than which object they
35
+ * are: a spec with a token in its timing is settled afresh each time the node's style is, and an
36
+ * animation that started again on every such commit would never get past its first frame.
37
+ */
38
+ function sameAnimation(a, b) {
39
+ if (a === b)
40
+ return true;
41
+ return (SPEC_FIELDS.every((field) => a[field] === b[field]) &&
42
+ a.easing.join() === b.easing.join() &&
43
+ a.range?.start === b.range?.start &&
44
+ a.range?.end === b.range?.end);
45
+ }
46
+ const SPEC_FIELDS = [
47
+ 'name',
48
+ 'duration',
49
+ 'delay',
50
+ 'iterations',
51
+ 'fill',
52
+ 'direction',
53
+ 'timeline',
54
+ ];
55
+ /**
56
+ * RN's `directEventTypes`: delivered to the target only, never up the tree. Everything else
57
+ * bubbles, which matches RN's `bubblingEventTypes` for the touch and input events that matter.
58
+ *
59
+ * ponytail: a hand-kept set rather than real ViewConfigs. It is wrong only for a nested
60
+ * scroll view, where an ancestor listening for the same direct event would not fire (correct)
61
+ * but a third-party component's custom direct event would bubble (harmless in practice).
62
+ * Swap it for per-component ViewConfig metadata if that ever matters.
63
+ */
64
+ /**
65
+ * The scroll events that mean a finger is dragging the list rather than tapping something in it.
66
+ *
67
+ * `topScrollBeginDrag` is the precise one; `topScroll` is here because a scroll view configured
68
+ * without drag events still reports movement, and a press surviving that is the bug either way.
69
+ */
70
+ const SCROLL_CANCELS_PRESS = new Set(['topScroll', 'topScrollBeginDrag']);
71
+ const DIRECT_EVENTS = new Set([
72
+ 'topScroll',
73
+ 'topScrollBeginDrag',
74
+ 'topScrollEndDrag',
75
+ 'topMomentumScrollBegin',
76
+ 'topMomentumScrollEnd',
77
+ 'topScrollToTop',
78
+ 'topContentSizeChange',
79
+ 'topLayout',
80
+ 'topLoad',
81
+ 'topLoadStart',
82
+ 'topLoadEnd',
83
+ 'topError',
84
+ 'topProgress',
85
+ 'topRefresh',
86
+ 'topTextLayout',
87
+ // react-native-screens. `topDismissed` is how a swipe-back or a native back button reaches
88
+ // JS, and it must not bubble: the stack above would see its child's pop as its own.
89
+ 'topDismissed',
90
+ 'topNativeDismissCancelled',
91
+ 'topWillAppear',
92
+ 'topAppear',
93
+ 'topWillDisappear',
94
+ 'topDisappear',
95
+ 'topHeaderHeightChange',
96
+ 'topTransitionProgress',
97
+ 'topFinishTransitioning',
98
+ // The tabs host reports its own selection, and only the host it happened in wants to know.
99
+ 'topTabSelected',
100
+ // Safe-area insets, reported by the provider that measured them.
101
+ 'topInsetsChange',
102
+ ]);
103
+ /**
104
+ * The event object a dispatch hands every listener along the path. Exported so another host can
105
+ * hand its listeners the same shape rather than a bare `{ nativeEvent }` that lacks the methods
106
+ * the type promises.
107
+ */
108
+ export class SyntheticEvent {
109
+ nativeEvent;
110
+ stopped = false;
111
+ constructor(nativeEvent) {
112
+ this.nativeEvent = nativeEvent;
113
+ }
114
+ stopPropagation() {
115
+ this.stopped = true;
116
+ }
117
+ isPropagationStopped() {
118
+ return this.stopped;
119
+ }
120
+ }
121
+ /**
122
+ * Events the native side will not emit unless a **prop** opts in, separately from anyone
123
+ * listening. `ShadowTree.cpp` guards the layout notification with `if (viewProps->onLayout)`,
124
+ * so a view that never sets the prop is never measured, however many listeners it has.
125
+ *
126
+ * React gets this for free: attaching a handler puts `onLayout` in the props object. A renderer
127
+ * that keeps listeners in its own registry has to set the prop deliberately, and the failure is
128
+ * silent - the callback simply never runs.
129
+ */
130
+ const EVENT_OPT_IN_PROPS = {
131
+ topLayout: 'onLayout',
132
+ /*
133
+ * Hover, which exists on this platform after all.
134
+ *
135
+ * React Native implements the W3C pointer events, and on iPadOS a connected trackpad or mouse
136
+ * produces them: `onPointerEnter` and `onPointerLeave` fire as the cursor crosses a view. A
137
+ * phone never sends them, so this costs a phone two props and nothing else, and it is what
138
+ * makes a hover style reachable on the one device that has a pointer.
139
+ */
140
+ topPointerEnter: 'onPointerEnter',
141
+ topPointerLeave: 'onPointerLeave',
142
+ };
143
+ /**
144
+ * The same opt-in, for events one view type guards with a prop of its own. Android's
145
+ * `ReactImageView` emits no load events at all unless `shouldNotifyLoadEvents` is set, which
146
+ * `Image.android.js` does whenever any load handler is attached. iOS ignores the prop.
147
+ */
148
+ const VIEW_EVENT_OPT_IN_PROPS = {
149
+ Image: {
150
+ topLoadStart: 'shouldNotifyLoadEvents',
151
+ topLoad: 'shouldNotifyLoadEvents',
152
+ topLoadEnd: 'shouldNotifyLoadEvents',
153
+ topError: 'shouldNotifyLoadEvents',
154
+ topProgress: 'shouldNotifyLoadEvents',
155
+ },
156
+ };
157
+ /**
158
+ * Props the engine itself writes or reads off a node, so never a misspelling: the style, the
159
+ * event opt-ins, and the bookkeeping keys that never reach native. See `checkProps`.
160
+ */
161
+ const ENGINE_PROPS = new Set([
162
+ 'style',
163
+ 'collapsable',
164
+ // Read by the cascade for `:disabled`, whether or not the native view takes it.
165
+ 'disabled',
166
+ 'intrinsicSize',
167
+ 'styleOverride',
168
+ ...Object.values(EVENT_OPT_IN_PROPS),
169
+ ...Object.values(VIEW_EVENT_OPT_IN_PROPS).flatMap((props) => Object.values(props)),
170
+ ]);
171
+ /** Touch events that drive the responder negotiation. */
172
+ const TOUCH_START = 'topTouchStart';
173
+ const TOUCH_MOVE = 'topTouchMove';
174
+ const TOUCH_END = 'topTouchEnd';
175
+ const TOUCH_CANCEL = 'topTouchCancel';
176
+ /**
177
+ * Canonical Fabric component names, taken from `RCTFabricComponentsPlugins.mm` and the C++
178
+ * `ComponentName[]` declarations rather than guessed. Legacy `RCT`-prefixed names also work
179
+ * today, but only through `componentNameByReactViewName`, a documented transition-period shim
180
+ * that strips the prefix and maps `Text` to `Paragraph`.
181
+ */
182
+ const VIEW_NAMES = {
183
+ view: 'View',
184
+ text: 'Paragraph',
185
+ image: 'Image',
186
+ 'scroll-view': 'ScrollView',
187
+ 'safe-area-view': 'SafeAreaView',
188
+ modal: 'ModalHostView',
189
+ switch: 'Switch',
190
+ 'text-input': 'TextInput',
191
+ 'activity-indicator': 'ActivityIndicatorView',
192
+ 'refresh-control': 'PullToRefreshView',
193
+ 'input-accessory-view': 'InputAccessoryView',
194
+ // Composites from the components package. Their host is a plain view; they are listed so a
195
+ // template that uses one without importing it is reported like any other host primitive.
196
+ pressable: 'View',
197
+ 'touchable-opacity': 'View',
198
+ 'image-background': 'View',
199
+ 'keyboard-avoiding-view': 'View',
200
+ // The windowing component; its host really is a native scroll view.
201
+ 'virtual-list': 'ScrollView',
202
+ };
203
+ /**
204
+ * The element names above, as they were before any app registered its own.
205
+ *
206
+ * Only these have a component in `@ng-native/components` to forget to import, which is what
207
+ * the dev-mode check below is about. A name added by `registerViewName` may have nothing behind
208
+ * it on purpose: an Expo view driven as a plain element is the whole point of
209
+ * `@ng-native/expo`, and warning about it would be noise.
210
+ */
211
+ const PRIMITIVE_NAMES = new Set(Object.keys(VIEW_NAMES));
212
+ /**
213
+ * The props each host primitive's native view is sent, by element name, as the components
214
+ * declare them: every input the element's component has, and the props it writes that no input
215
+ * carries. What the development check for a misspelt prop compares against. See
216
+ * `declareNativeProps`.
217
+ */
218
+ const DECLARED_PROPS = new Map();
219
+ /**
220
+ * Declare props the native view behind an element accepts. `@ng-native/components` calls this
221
+ * once per host primitive with the component's inputs; an app driving a primitive's native view
222
+ * with a prop the component has no input for can declare that prop too.
223
+ */
224
+ export function declareNativeProps(elementName, props) {
225
+ let declared = DECLARED_PROPS.get(elementName);
226
+ if (!declared)
227
+ DECLARED_PROPS.set(elementName, (declared = new Set()));
228
+ for (const prop of props)
229
+ declared.add(prop);
230
+ }
231
+ /**
232
+ * Mark a host element as owned by its component. The components package calls this from each
233
+ * host primitive's constructor; see `EngineNode.claimed`.
234
+ */
235
+ export function claimHost(node) {
236
+ node.claimed = true;
237
+ }
238
+ /**
239
+ * Mark an element as the host of a component. The platform adapter calls this from
240
+ * `RendererFactory2.createRenderer`, which Angular calls once for every component it mounts,
241
+ * with the host element in hand, whatever the component's selector.
242
+ */
243
+ export function markComponentHost(node) {
244
+ node.componentHost = true;
245
+ }
246
+ /** `text-input` -> `TextInput`: how the components package names the class for an element. */
247
+ const className = (element) => element.replace(/(^|-)([a-z])/g, (_, __, c) => c.toUpperCase());
248
+ /** Names Android registers differently. */
249
+ export const ANDROID_VIEW_NAMES = {
250
+ switch: 'AndroidSwitch',
251
+ 'text-input': 'AndroidTextInput',
252
+ 'activity-indicator': 'AndroidProgressBar',
253
+ 'refresh-control': 'AndroidSwipeRefreshLayout',
254
+ // Neither exists on Android; RN's own wrappers render a plain view there too.
255
+ 'safe-area-view': 'View',
256
+ 'input-accessory-view': 'View',
257
+ };
258
+ /**
259
+ * The platform the host reported at startup. Defaults to iOS, which is what the view-name table
260
+ * above assumes too.
261
+ *
262
+ * Exposed because a few of React Native's JavaScript wrappers behave differently per platform in
263
+ * ways nothing native can infer: `Text.js` makes a text an accessibility element on iOS always,
264
+ * and on Android only when it can be pressed.
265
+ */
266
+ let platformOS = 'ios';
267
+ export function nativePlatform() {
268
+ return platformOS;
269
+ }
270
+ /**
271
+ * Call once at startup with `Platform.OS`. The view names above are the iOS/canonical ones, so
272
+ * only the mapping half is a no-op elsewhere.
273
+ */
274
+ export function registerPlatformComponents(platform) {
275
+ platformOS = platform;
276
+ if (platform !== 'android')
277
+ return;
278
+ for (const [element, viewName] of Object.entries(ANDROID_VIEW_NAMES)) {
279
+ registerViewName(element, viewName);
280
+ }
281
+ }
282
+ /**
283
+ * Props RN's JS wrappers apply before the caller's, per native view name.
284
+ *
285
+ * The native ScrollView does not clip on its own; RN's `ScrollView.js` supplies
286
+ * `{flexGrow, flexShrink, flexDirection, overflow: 'scroll'}` as a base style. Omit it and the
287
+ * content spills straight out of the scroll view's bounds, which reads as a layout bug in the
288
+ * content rather than a missing default.
289
+ *
290
+ * Caller props and styles are applied on top, so any of this can be overridden.
291
+ */
292
+ const DEFAULT_PROPS = {
293
+ ScrollView: { flexGrow: 1, flexShrink: 1, flexDirection: 'column', overflow: 'scroll' },
294
+ // RN's Image.ios.js: without it a border radius rounds the view but not the picture.
295
+ Image: { overflow: 'hidden' },
296
+ // RN's Modal.js gives the native host this; without it the modal does not lay out.
297
+ ModalHostView: { position: 'absolute' },
298
+ // RN's InputAccessoryView.js does the same for the keyboard bar.
299
+ InputAccessoryView: { position: 'absolute' },
300
+ // RN's Text.js ends truncated text in an ellipsis; native's own default is to clip. A default
301
+ // rather than a prop the component writes, so `text-overflow` in a stylesheet can change it.
302
+ Paragraph: { ellipsizeMode: 'tail' },
303
+ };
304
+ const RAW_TEXT = 'RawText';
305
+ /**
306
+ * A `<text>` nested inside another `<text>` is a span, not a paragraph.
307
+ *
308
+ * Note the name: the C++ component really is called `Text`, but that name is **unreachable
309
+ * from JS**. `ComponentDescriptorRegistry::at` puts every lookup through
310
+ * `componentNameByReactViewName`, which rewrites `Text` to `Paragraph` and `VirtualText` to
311
+ * `Text`. Sending the canonical `Text` therefore yields a Paragraph nested inside a Paragraph,
312
+ * which Fabric treats as an inline view attachment: the run renders top-aligned and does not
313
+ * inherit the parent's text attributes. It looks like a superscript.
314
+ */
315
+ const VIRTUAL_TEXT = 'VirtualText';
316
+ const DEFAULT_VIEW = 'View';
317
+ /** A top-level text, the one view that aligns its lines. A nested text is a `VirtualText`. */
318
+ const PARAGRAPH = 'Paragraph';
319
+ const textDirection = (value) => value === 'ltr' || value === 'rtl' ? value : undefined;
320
+ /**
321
+ * Resolve a paragraph's `text-align` against its direction, as CSS does.
322
+ *
323
+ * React Native reads `left` and `right` relative to the layout direction: in a right-to-left
324
+ * layout `RCTAttributedTextUtils` swaps them, so its `left` is the start edge. `start` and `end`
325
+ * are therefore `left` and `right` whatever the direction. Where the app set a direction, the
326
+ * default is `start`, as on the web, rather than native's natural alignment, which follows the
327
+ * app's language and not the subtree; a physical `left` or `right` is swapped back so it stays on
328
+ * that side; and the paragraph's base writing direction follows unless a `writing-direction` says
329
+ * otherwise, which is what puts neutral characters such as punctuation on the right side.
330
+ */
331
+ function alignText(props, direction) {
332
+ const align = props['textAlign'];
333
+ if (!direction) {
334
+ if (align !== undefined)
335
+ props['textAlign'] = LOGICAL_ALIGN.get(align) ?? align;
336
+ return;
337
+ }
338
+ props['textAlign'] = TEXT_ALIGN[direction].get(align ?? 'start') ?? align;
339
+ props['writingDirection'] ??= direction;
340
+ }
341
+ /** React Native's `textAlign` for CSS's logical values, which means the same in either direction. */
342
+ const LOGICAL_ALIGN = new Map([
343
+ ['start', 'left'],
344
+ ['end', 'right'],
345
+ ]);
346
+ /** React Native's `textAlign` for each CSS value, in a layout of each direction. */
347
+ const TEXT_ALIGN = {
348
+ ltr: new Map([...LOGICAL_ALIGN, ['auto', 'left'], ['left', 'left'], ['right', 'right']]),
349
+ rtl: new Map([...LOGICAL_ALIGN, ['auto', 'left'], ['left', 'right'], ['right', 'left']]),
350
+ };
351
+ /** The native modal host, which is left out of a commit while `visible` is false. */
352
+ const MODAL_HOST = 'ModalHostView';
353
+ /**
354
+ * Register a codegen'd third-party Fabric component, e.g.
355
+ * `registerViewName('rns-screen', 'RNSScreen')`, which is how the router reaches
356
+ * react-native-screens.
357
+ *
358
+ * `defaultProps` is for a component whose React wrapper supplies a base style in JS, the way
359
+ * `ScrollView` and `Modal` do below. Driving the native component directly skips that wrapper,
360
+ * and skipping it is not cosmetic: `RNSScreenStackHeaderConfig` without its `position: absolute`
361
+ * joins the screen's flex layout and pushes every page down by the height of the header.
362
+ */
363
+ export function registerViewName(elementName, viewName, defaultProps) {
364
+ VIEW_NAMES[elementName] = viewName;
365
+ if (!defaultProps)
366
+ return;
367
+ for (const name of typeof viewName === 'string' ? [viewName] : [viewName.ios, viewName.android]) {
368
+ DEFAULT_PROPS[name] = defaultProps;
369
+ }
370
+ }
371
+ /** An element's registered view name, for the platform the host reported. */
372
+ function registeredViewName(elementName) {
373
+ const name = VIEW_NAMES[elementName];
374
+ if (name === undefined || typeof name === 'string')
375
+ return name;
376
+ return platformOS === 'android' ? name.android : name.ios;
377
+ }
378
+ /**
379
+ * The views Fabric measures from their text, which a change of the system text size resizes:
380
+ * a paragraph, and a text input on either platform. See `Engine.remeasureText`.
381
+ */
382
+ const MEASURED_VIEWS = new Set(['Paragraph', 'TextInput', 'AndroidTextInput']);
383
+ /** The views that take a cursor, on either platform. */
384
+ const TEXT_INPUTS = new Set(['TextInput', 'AndroidTextInput']);
385
+ /** The views that scroll their content, which `focus` brings a field into view within. */
386
+ const SCROLL_VIEWS = new Set(['ScrollView', 'AndroidHorizontalScrollView']);
387
+ /** Element name -> the element name of the ancestor it commits into. See `registerHoist`. */
388
+ const HOISTS = {};
389
+ /** The `ancestorName`s, so a commit asks only those nodes what to hoist into them. */
390
+ const HOIST_TARGETS = new Set();
391
+ /**
392
+ * Commit every `elementName` as a direct child of its nearest `ancestorName`, wherever the
393
+ * template put it, e.g. `registerHoist('native-header', 'screen')`.
394
+ *
395
+ * For a native component that reads a configuration node from its own direct children and
396
+ * nowhere else. `RNSScreen.mm` finds its header that way, so a header written inside a
397
+ * `safe-area-view`, or inside any component a page wraps itself in, was never found: no title,
398
+ * no back button, and nothing said why. The retained tree keeps the node where Angular put it,
399
+ * so styles, `@if` and removal all behave as written; only what reaches Fabric moves. It lands
400
+ * just after the ancestor's child it was written in, or after all of them when it was written in
401
+ * the ancestor itself, which keeps a page's scroll view ahead of its header as UIKit's large
402
+ * title wants. A node with no such ancestor commits where it is.
403
+ */
404
+ export function registerHoist(elementName, ancestorName, options = {}) {
405
+ HOISTS[elementName] = ancestorName;
406
+ HOIST_TARGETS.add(ancestorName);
407
+ if (options.standIn)
408
+ STAND_INS[elementName] = options.standIn;
409
+ }
410
+ /** Element name -> the props it leaves behind in its ancestor. See `HoistOptions.standIn`. */
411
+ const STAND_INS = {};
412
+ /**
413
+ * Unregistered names commit as a plain RCTView, because that is what an **Angular component
414
+ * host element** is. Angular always creates one (`<x-card>` is a real node in the tree), and
415
+ * it carries the component's host bindings, so it cannot be elided. Guessing a native view
416
+ * name from the element name instead produces `Unimplemented component: <XCard>` on device.
417
+ *
418
+ * The cost is that a typo'd element name would render as an empty view without a word, and
419
+ * Angular's own unknown-element check cannot help (it no-ops without a DOM). So in dev the
420
+ * engine reports a name that nothing accounts for: not a view name, not a component host (see
421
+ * `markComponentHost`), and not an element selector any template declared (`declareElement`).
422
+ * Use an attribute selector (`selector: '[card]'`) on a component to avoid the extra view.
423
+ */
424
+ /**
425
+ * A run of text as a paragraph shows it: without the space at the paragraph's very start or end.
426
+ *
427
+ * Angular collapses template whitespace to a single space rather than removing it, so
428
+ * `<text>\n {{ name }}\n</text>` arrives as ` Ada `. A browser drops that space where a line
429
+ * starts or ends; React Native's Text draws it, and every label written across two lines, which
430
+ * a formatter will do to any long one, comes out indented. Spaces between runs are left alone,
431
+ * as they are in HTML.
432
+ *
433
+ * ponytail: worked out when the run itself changes. A run that stops being the first or last
434
+ * because a sibling arrived keeps its old trim until it next changes, which is a space at most.
435
+ */
436
+ function paragraphText(node) {
437
+ let root = node.parent;
438
+ if (root?.name !== 'text')
439
+ return node.text;
440
+ // The commonest paragraph by far: one run and nothing else, so it is both the first and last.
441
+ if (root.children.length === 1 && root.parent?.name !== 'text')
442
+ return node.text.trim();
443
+ while (root.parent?.name === 'text')
444
+ root = root.parent;
445
+ const runs = [];
446
+ const collect = (from) => {
447
+ for (const child of from.children) {
448
+ if (child.kind === 'text') {
449
+ if (child.text)
450
+ runs.push(child);
451
+ }
452
+ else if (child.name === 'text')
453
+ collect(child);
454
+ }
455
+ };
456
+ collect(root);
457
+ let text = node.text;
458
+ if (runs[0] === node)
459
+ text = text.replace(/^\s+/, '');
460
+ if (runs[runs.length - 1] === node)
461
+ text = text.replace(/\s+$/, '');
462
+ return text;
463
+ }
464
+ /**
465
+ * The native view a node commits as: `Paragraph` for a `<text>`, `VirtualText` for one nested in
466
+ * another, `RawText` for a run of text, and `View` for anything unregistered, which is what an
467
+ * Angular component's host element is.
468
+ *
469
+ * Exported for tools that show a template beside what it becomes, such as the X-ray in the
470
+ * documentation's course, which labels `@ng-native/web`'s DOM with these names. It reads the same
471
+ * table the commit does, so after `registerPlatformComponents('android')` it answers for Android.
472
+ */
473
+ export function viewNameOf(node) {
474
+ if (node.kind === 'text')
475
+ return RAW_TEXT;
476
+ // RN models nested text as a span inside a paragraph: `Paragraph > [RawText, Text > RawText]`.
477
+ // Committing a nested `<text>` as another Paragraph gives a paragraph inside a paragraph,
478
+ // which lays out as a separate block instead of flowing inline.
479
+ if (node.name === 'text' && node.parent?.name === 'text')
480
+ return VIRTUAL_TEXT;
481
+ return registeredViewName(node.name) ?? DEFAULT_VIEW;
482
+ }
483
+ /** Whether `node` is `ancestor` or somewhere under it. */
484
+ function isWithin(node, ancestor) {
485
+ for (let at = node; at; at = at.parent)
486
+ if (at === ancestor)
487
+ return true;
488
+ return false;
489
+ }
490
+ /**
491
+ * The DOM events Angular's `@defer (on interaction)` and `(on hover)` listen for on the trigger
492
+ * element, as the engine events that mean the same thing here.
493
+ *
494
+ * Interaction is `click` and `keydown`: a touch that lifts on the trigger or anything inside it,
495
+ * and focus reaching it, which is what a keyboard or a screen reader does before acting on it.
496
+ * Hover is `mouseenter`, `mouseover` and `focusin`: a pointer entering, which only a trackpad or a
497
+ * mouse produces, and focus. Anything else is not a trigger and is ignored.
498
+ */
499
+ const DEFER_TRIGGER_EVENTS = {
500
+ click: ['topTouchEnd'],
501
+ keydown: ['topFocus'],
502
+ mouseenter: ['topPointerEnter'],
503
+ focusin: ['topFocus'],
504
+ };
505
+ /** Each trigger listener's teardowns, by DOM event name. Off the node, which rarely has one. */
506
+ const triggerListeners = new WeakMap();
507
+ /**
508
+ * The react tag of the next view any engine creates.
509
+ *
510
+ * Even, because surface root tags are odd (1, 11, 21, ...): an odd view tag eventually collides
511
+ * with a root, and UIKit throws inside `-[RCTViewComponentView mountChildComponentView:]` while
512
+ * adding a view to itself, a native crash with no JS error at all.
513
+ *
514
+ * And far above 2, where React starts. Native keeps one registry of views by tag for every
515
+ * surface, and React numbers its own views 2, 4, 6... on any surface it renders; a development
516
+ * build always has one, LogBox, which renders the first `console.warn` on a surface of its own.
517
+ * Starting where React starts put two views under one tag, and the app crashed natively in
518
+ * `-[RCTComponentViewRegistry dequeueComponentViewWithComponentHandle:tag:]`. One counter for
519
+ * every engine, so two Angular surfaces do not collide with each other either.
520
+ *
521
+ * ponytail: room for about 530 million views in one run of the app before a tag would pass
522
+ * int32, which native keeps them as; recycled and cloned views take no new tag. Wrapping would
523
+ * need to skip tags still mounted.
524
+ */
525
+ const FIRST_TAG = 2 ** 30;
526
+ let lastTag = FIRST_TAG - 2;
527
+ const nextTag = () => (lastTag += 2);
528
+ let hoverReported = false;
529
+ /**
530
+ * Said once, in development, the first time a `@defer (on hover)` trigger is registered: a phone
531
+ * has no pointer, so on one the block waits for focus alone.
532
+ */
533
+ function reportHover(host) {
534
+ if (hoverReported || !(host instanceof Engine) || !host.dev)
535
+ return;
536
+ hoverReported = true;
537
+ console.warn('[angular-native] @defer (on hover) fires when a pointer enters the trigger, which takes a trackpad or a mouse (an iPad, or an Android device with one), or when the trigger takes focus. On a phone it waits for focus alone; add another trigger, such as on interaction or on viewport.');
538
+ }
539
+ /** Whether a value is one of the engine's own nodes. */
540
+ export function isEngineNode(value) {
541
+ return value instanceof RetainedNode;
542
+ }
543
+ /**
544
+ * A retained node, as a class so every node has one shape from its first line: Hermes lays a
545
+ * constructed object out once, where the object-spread this replaced built a literal and copied
546
+ * eighteen fields across for each of the thousands of nodes a screen makes.
547
+ *
548
+ * The first three methods are what Angular's `animate.enter` and `animate.leave` ask an element
549
+ * for directly, rather than through `Renderer2`; everything else those instructions do goes through
550
+ * the renderer. `getAnimations` answers from the transitions the engine is actually running,
551
+ * which is what tells Angular how long to wait before removing a leaving element.
552
+ */
553
+ /**
554
+ * What the Metro preset's `Node` stand-in recognises an engine node by, so that Angular's
555
+ * `instanceof Node` checks pass for these and for nothing else.
556
+ */
557
+ const ENGINE_NODE = Symbol.for('ng-native.node');
558
+ class RetainedNode {
559
+ static {
560
+ Object.defineProperty(RetainedNode.prototype, ENGINE_NODE, { value: true });
561
+ }
562
+ props = {};
563
+ text = '';
564
+ children = [];
565
+ parent = null;
566
+ listeners = null;
567
+ classes = null;
568
+ sheet = null;
569
+ hostSheet = null;
570
+ customProperties = null;
571
+ styleCache = null;
572
+ styleDirty = true;
573
+ styleCommitted = null;
574
+ ownStyle = undefined;
575
+ claimed = undefined;
576
+ dormantHoists = null;
577
+ committed = null;
578
+ committedUnder = null;
579
+ kept = undefined;
580
+ remeasure = undefined;
581
+ propsDirty = false;
582
+ structureDirty = false;
583
+ subtreeDirty = false;
584
+ transitions;
585
+ playing;
586
+ scrolled;
587
+ kind;
588
+ name;
589
+ /** See `HostNode.host`. */
590
+ host;
591
+ constructor(kind, name, host) {
592
+ this.kind = kind;
593
+ this.name = name;
594
+ this.host = host;
595
+ }
596
+ get tagName() {
597
+ return this.name;
598
+ }
599
+ /** Angular's dev-mode assertion compares this against `Node.ELEMENT_NODE`. */
600
+ get nodeType() {
601
+ return 1;
602
+ }
603
+ getAnimations() {
604
+ const running = [];
605
+ const playing = this.playing;
606
+ if (playing && !playing.done) {
607
+ running.push({
608
+ animationName: playing.spec.name,
609
+ effect: {
610
+ // `animate.enter` reads this to know how long to wait. A null count is `infinite`,
611
+ // which is what the Web Animations shape spells as Infinity.
612
+ getTiming: () => ({
613
+ duration: playing.spec.duration * (playing.spec.iterations ?? Infinity),
614
+ delay: playing.spec.delay,
615
+ iterations: playing.spec.iterations ?? Infinity,
616
+ }),
617
+ },
618
+ });
619
+ }
620
+ for (const [property, transition] of this.transitions ?? []) {
621
+ if (transition.done)
622
+ continue;
623
+ running.push({
624
+ transitionProperty: property,
625
+ effect: {
626
+ getTiming: () => ({ duration: transition.duration, delay: 0, iterations: 1 }),
627
+ },
628
+ });
629
+ }
630
+ return running;
631
+ }
632
+ /**
633
+ * Where another node is relative to this one, as the DOM's `compareDocumentPosition` answers:
634
+ * the bits for preceding (2), following (4), containing (8) and contained (16), in tree order.
635
+ * Signal Forms asks, to focus the first of a form's fields in the order the screen shows them.
636
+ */
637
+ compareDocumentPosition(other) {
638
+ if (other === this)
639
+ return 0;
640
+ const path = (node) => {
641
+ const chain = [];
642
+ for (let at = node; at; at = at.parent) {
643
+ chain.unshift(at);
644
+ }
645
+ return chain;
646
+ };
647
+ const mine = path(this);
648
+ const theirs = path(other);
649
+ if (mine[0] !== theirs[0])
650
+ return 1 | 32 | 4;
651
+ let depth = 0;
652
+ while (depth < mine.length && depth < theirs.length && mine[depth] === theirs[depth])
653
+ depth++;
654
+ if (depth === mine.length)
655
+ return 4 | 16;
656
+ if (depth === theirs.length)
657
+ return 2 | 8;
658
+ const siblings = mine[depth - 1].children;
659
+ return siblings.indexOf(theirs[depth]) < siblings.indexOf(mine[depth]) ? 2 : 4;
660
+ }
661
+ /**
662
+ * What Signal Forms' `focusBoundControl()` calls on a field's element, and what a DOM element
663
+ * would do: see `HostEngine.focus`.
664
+ */
665
+ focus() {
666
+ this.host.focus(this);
667
+ }
668
+ get classList() {
669
+ const classes = this.classes;
670
+ return { contains: (name) => classes?.has(name) ?? false };
671
+ }
672
+ /**
673
+ * What `@defer (on interaction)` and `(on hover)` call on their trigger element, the one place
674
+ * Angular listens on an element directly rather than through `Renderer2`. See
675
+ * `DEFER_TRIGGER_EVENTS`.
676
+ */
677
+ addEventListener(type, listener) {
678
+ const events = DEFER_TRIGGER_EVENTS[type];
679
+ if (!events)
680
+ return;
681
+ let byListener = triggerListeners.get(this);
682
+ if (!byListener)
683
+ triggerListeners.set(this, (byListener = new Map()));
684
+ let byType = byListener.get(listener);
685
+ if (!byType)
686
+ byListener.set(listener, (byType = new Map()));
687
+ if (byType.has(type))
688
+ return;
689
+ if (type === 'mouseenter')
690
+ reportHover(this.host);
691
+ const stops = events.map((event) => this.host.setEventListener(this, event, listener));
692
+ byType.set(type, () => {
693
+ for (const stop of stops)
694
+ stop();
695
+ });
696
+ }
697
+ removeEventListener(type, listener) {
698
+ const byType = triggerListeners.get(this)?.get(listener);
699
+ byType?.get(type)?.();
700
+ byType?.delete(type);
701
+ }
702
+ }
703
+ /**
704
+ * A prop whose value is a colour, and so has to go through `processColor` before native reads it.
705
+ *
706
+ * The `Android` suffix is part of the test because React Native names its platform-specific props
707
+ * that way round: `underlineColorAndroid` is a colour and does not end in `color`, and Android's
708
+ * `ColorPropConverter` takes a number or a platform-colour map and refuses a string, so an unnamed
709
+ * one arrives as the wrong type. `iOS`-suffixed props are not in the set because none of them are
710
+ * colours - `shouldRasterizeIOS` is a boolean.
711
+ */
712
+ const IS_COLOR_PROP = /color(android)?$/i;
713
+ /** Answers per key name, once: the regex ran for every prop of every node in every commit. */
714
+ const colorProps = new Map();
715
+ function isColorProp(key) {
716
+ let answer = colorProps.get(key);
717
+ if (answer === undefined)
718
+ colorProps.set(key, (answer = IS_COLOR_PROP.test(key)));
719
+ return answer;
720
+ }
721
+ const ASSET_PROPS = new Set(['source', 'defaultSource', 'loadingIndicatorSource']);
722
+ /**
723
+ * Props whose value is a list of objects with a `color` inside.
724
+ *
725
+ * Colour processing keys off the *property name*, which cannot see a colour nested in an array.
726
+ * That matters more than it sounds: with `enableNativeCSSParsing` off, which is the default,
727
+ * Fabric does not parse a colour string either, so an unprocessed nested colour is not
728
+ * approximated, it is dropped.
729
+ */
730
+ const NESTED_COLOR_LIST_PROPS = new Set(['boxShadow']);
731
+ /** Styles arrive as objects, arrays, nested arrays and nulls. Reduce to one object. */
732
+ function flattenStyle(value, into) {
733
+ if (!value)
734
+ return into;
735
+ if (Array.isArray(value)) {
736
+ for (const entry of value)
737
+ flattenStyle(entry, into);
738
+ return into;
739
+ }
740
+ if (typeof value === 'object')
741
+ Object.assign(into, value);
742
+ return into;
743
+ }
744
+ /** The timing a rule can set apart from a property list, and the part of the spec each sets. */
745
+ const TRANSITION_TIMING = {
746
+ $transitionDuration: 'duration',
747
+ $transitionDelay: 'delay',
748
+ $transitionEasing: 'easing',
749
+ };
750
+ /**
751
+ * The transition spec, with any timing set by a rule of its own laid over it: `.duration-700`
752
+ * beside `.transition`. A rule that writes the timing itself compiles it to `null`, which is how
753
+ * a stronger transition keeps its own.
754
+ *
755
+ * Every key is taken out of `props`: they are instructions for this engine, and nothing native
756
+ * has ever heard of them. See `animated` for why each `delete` is guarded.
757
+ */
758
+ function transitionSpec(props) {
759
+ // Keyed `$transition` by the compiler, so a view's own `transition` prop reaches it.
760
+ let spec = props['$transition'];
761
+ if (spec !== undefined)
762
+ delete props['$transition'];
763
+ for (const [key, part] of Object.entries(TRANSITION_TIMING)) {
764
+ if (!(key in props))
765
+ continue;
766
+ const value = props[key];
767
+ delete props[key];
768
+ if (value === null || spec === undefined)
769
+ continue;
770
+ const timed = {};
771
+ for (const name of Object.keys(spec))
772
+ timed[name] = { ...spec[name], [part]: value };
773
+ spec = timed;
774
+ }
775
+ return spec;
776
+ }
777
+ /**
778
+ * The keys CSS's `translate`, `rotate` and `scale` compile to, in the order they apply. The
779
+ * compiler's `INDIVIDUAL_TRANSFORMS` in `packages/metro/css/properties.cjs` writes the same three.
780
+ */
781
+ const INDIVIDUAL_TRANSFORMS = ['__translate', '__rotate', '__scale'];
782
+ /**
783
+ * Fold the individual transform properties into the one list native has.
784
+ *
785
+ * They cascade, transition and animate as properties of their own, so this runs last, on what
786
+ * the node is about to paint. CSS Transforms 2 applies translate, then rotate, then scale, then
787
+ * `transform`, and a list applies left to right, so that is the order they are written in.
788
+ */
789
+ function composeTransform(node, props) {
790
+ const [translate, rotate, scale] = INDIVIDUAL_TRANSFORMS.map((key) => props[key]);
791
+ if (translate === undefined && rotate === undefined && scale === undefined)
792
+ return props;
793
+ // Guarded, as `animated` guards its own: a `delete` is what sends an object to Hermes's slow
794
+ // dictionary layout, and most nodes have none of these.
795
+ for (const key of INDIVIDUAL_TRANSFORMS)
796
+ if (key in props)
797
+ delete props[key];
798
+ const transform = props['transform'];
799
+ const parts = [translate, rotate, scale, transform];
800
+ const last = node.composedTransform;
801
+ let value;
802
+ if (last && last.parts.every((part, i) => part === parts[i])) {
803
+ value = last.value;
804
+ }
805
+ else {
806
+ // A bound style's transform is still the CSS string: see `inline-transform.ts`.
807
+ const own = typeof transform === 'string' ? transformList(transform) : transform;
808
+ value = [translate, rotate, scale, own].flatMap((part) => (Array.isArray(part) ? part : []));
809
+ node.composedTransform = { parts, value };
810
+ }
811
+ if (value.length)
812
+ props['transform'] = value;
813
+ else
814
+ delete props['transform'];
815
+ return props;
816
+ }
817
+ /**
818
+ * Fabric *merges* the raw props handed to a clone onto the node's existing props, so a prop
819
+ * that has gone away has to be sent explicitly as `null`. Sending the whole prop object would
820
+ * silently keep removed props alive.
821
+ */
822
+ function diffProps(prev, next) {
823
+ let payload = null;
824
+ for (const key of Object.keys(next)) {
825
+ if (!Object.is(prev[key], next[key]))
826
+ (payload ??= {})[key] = next[key];
827
+ }
828
+ for (const key of Object.keys(prev)) {
829
+ if (!(key in next))
830
+ (payload ??= {})[key] = null;
831
+ }
832
+ return payload;
833
+ }
834
+ /**
835
+ * The prop a replaced element, an image, puts its own pixel size in: `{ width, height }`.
836
+ *
837
+ * Not a style, because a style binding is inline style and beats every rule, so a picture's own
838
+ * 600 by 600 would override `class="size-11"`. CSS gives a replaced element's natural size the
839
+ * lowest precedence of all, below any rule and any bound style, and that is what this is for.
840
+ * It never reaches native.
841
+ */
842
+ const INTRINSIC_SIZE = 'intrinsicSize';
843
+ /**
844
+ * The prop a component puts the style it must have the last word on in, over the caller's.
845
+ *
846
+ * The other end of the scale from `intrinsicSize`. Angular merges a component's host style
847
+ * binding under the template's, so a caller's `[style]="{flex: 1}"` beats the host's own
848
+ * `flex: 0`; that is the right order for a default and the wrong one for an adjustment the
849
+ * component is making on the caller's behalf, which RN composes after the caller's style. A
850
+ * keyboard-avoiding view's padding, height and flex are that. Applied after inline style, a key
851
+ * at a time, so every other property the caller wrote still applies. It never reaches native.
852
+ */
853
+ const STYLE_OVERRIDE = 'styleOverride';
854
+ const isSet = (value) => value !== undefined && value !== null;
855
+ /**
856
+ * Size a box from its content's natural size, where nothing else did, as CSS sizes an `<img>`.
857
+ *
858
+ * Neither dimension set: the natural size, or the natural width alone when the author gave an
859
+ * aspect ratio for Yoga to take the height from. One dimension set: the other follows the
860
+ * picture's proportions, through `aspectRatio`, unless the author gave one of their own.
861
+ */
862
+ function applyIntrinsicSize(props, size) {
863
+ const width = isSet(props['width']);
864
+ const height = isSet(props['height']);
865
+ if (width && height)
866
+ return;
867
+ const ownRatio = isSet(props['aspectRatio']);
868
+ if (!width && !height) {
869
+ props['width'] = size.width;
870
+ if (!ownRatio)
871
+ props['height'] = size.height;
872
+ return;
873
+ }
874
+ if (!ownRatio && size.width > 0 && size.height > 0) {
875
+ props['aspectRatio'] = size.width / size.height;
876
+ }
877
+ }
878
+ const now = () => globalThis.performance?.now?.() ?? Date.now();
879
+ function sameHandles(a, b) {
880
+ return a.length === b.length && a.every((handle, i) => handle === b[i]);
881
+ }
882
+ const FRAME_BUDGET_MS = 8;
883
+ export class Engine {
884
+ /** Scopes the CSS resolver's memo. Bumped once per commit. */
885
+ styleEpoch = 0;
886
+ /** The node `:focus` currently applies to. */
887
+ focusedNode = null;
888
+ stats = {
889
+ commits: 0,
890
+ lastCommitMs: 0,
891
+ firstCommitMs: 0,
892
+ worstCommitMs: 0,
893
+ slowCommits: 0,
894
+ worstRenderMs: 0,
895
+ createdNodes: 0,
896
+ clonedNodes: 0,
897
+ };
898
+ eventsRegistered = false;
899
+ root = new RetainedNode('element', 'root', this);
900
+ fabric;
901
+ rootTag;
902
+ styles;
903
+ /** Whether to run the development checks, which a release build skips. */
904
+ dev;
905
+ now;
906
+ onDirty;
907
+ onError;
908
+ /** Nodes with at least one transition in flight, so a frame walks those and nothing else. */
909
+ running = new Set();
910
+ /** Nodes playing a `@keyframes` animation. */
911
+ playing = new Set();
912
+ /**
913
+ * Elements of a hoisted name (`registerHoist`) that are in the tree, so a commit need not search
914
+ * for them. Only those in the tree: see `releaseDetached`.
915
+ */
916
+ hoisted = new Set();
917
+ /** Whether a child list has lost a member since the last commit. See `releaseDetached`. */
918
+ removedSinceCommit = false;
919
+ /**
920
+ * `@keyframes` by name, across every sheet seen so far. One registry rather than one per sheet,
921
+ * because that is the scope CSS gives them: a name defined in a global stylesheet is usable
922
+ * from a component's. The cost is that two components defining the same name collide, last one
923
+ * in wins, exactly as two stylesheets in a document would.
924
+ */
925
+ keyframes = new Map();
926
+ /** Element names already reported as unclaimed, so a list of a thousand rows reports once. */
927
+ reported = new Set();
928
+ processColor;
929
+ /**
930
+ * Whether any sheet seen so far asks about a node's position among its siblings. Until one
931
+ * does, a child list can move without anything else needing to be looked at again.
932
+ */
933
+ structuralSheets = false;
934
+ resolveAssetSource;
935
+ constructor(fabric, rootTag, options = {}) {
936
+ this.fabric = fabric;
937
+ this.rootTag = rootTag;
938
+ const conditions = options.conditions ?? { width: 0, height: 0, colorScheme: 'light' };
939
+ this.styles = new StyleResolver(options.globalStyles ?? null, conditions);
940
+ this.viewportSize = { width: conditions.width, height: conditions.height };
941
+ if (options.tokens)
942
+ this.styles.setRootTokens(options.tokens);
943
+ this.structuralSheets = options.globalStyles?.structural === true;
944
+ this.dev = options.dev ?? globalThis.__DEV__ === true;
945
+ if (this.dev)
946
+ this.styles.onUndefinedToken = (name, props) => this.reportUndefinedToken(name, props);
947
+ this.now = options.now ?? (() => globalThis.performance?.now?.() ?? Date.now());
948
+ this.onDirty = options.onDirty;
949
+ this.onError = options.onError;
950
+ this.registerKeyframes(options.globalStyles);
951
+ this.processColor = options.processColor ?? ((value) => value);
952
+ this.resolveAssetSource = options.resolveAssetSource ?? ((value) => value);
953
+ this.scrollDriver = options.nativeAnimated
954
+ ? new NativeScrollDriver(options.nativeAnimated)
955
+ : null;
956
+ // Eagerly, not on the first listener. Fabric holds exactly one event handler and
957
+ // `ReactFabric` installs React's at module scope, so until we claim it every native event
958
+ // lands in React's event plugins. Those look the name up in a view config registry we never
959
+ // populate and throw `Unsupported top level event type` from inside the native dispatch, or
960
+ // read `instanceHandle.stateNode.canonical` off our own node and throw on `canonical`.
961
+ // Nothing here emits events until a component asks for one, so waiting looks safe; it is not.
962
+ // `RNSScreen` fires `onWillAppear`/`onHeaderHeightChange` on its own during a push, with no
963
+ // listener anywhere, and the throw unwinds through the mounting transaction that pushed it.
964
+ // The next commit then reads freed props and the process dies in `updateProps`.
965
+ this.registerEventHandler();
966
+ }
967
+ /**
968
+ * A rotation, a window resize, or the system switching to dark mode.
969
+ *
970
+ * None of those change a single node, so nothing would be dirty and nothing would re-render.
971
+ * Marking the root is what gets the tree walked again; every cached style is already invalid,
972
+ * because each carries the conditions version it was resolved under.
973
+ */
974
+ /**
975
+ * Move `:focus`, invalidating only the two nodes involved.
976
+ *
977
+ * Precise rather than by generation: unlike a rotation, the engine knows exactly which nodes
978
+ * changed, and a keystroke's worth of focus movement should not invalidate the tree.
979
+ */
980
+ setFocused(node) {
981
+ if (this.focusedNode === node)
982
+ return;
983
+ if (this.focusedNode) {
984
+ this.focusedNode.focused = false;
985
+ this.markProps(this.focusedNode);
986
+ }
987
+ this.focusedNode = node;
988
+ if (node) {
989
+ node.focused = true;
990
+ this.markProps(node);
991
+ }
992
+ this.commit();
993
+ }
994
+ /**
995
+ * Set or clear `:active` along a responder chain, marking each node so its style re-resolves.
996
+ *
997
+ * Returns whether anything moved, because the caller has to commit: a gesture changes no
998
+ * binding, so nothing marks an Angular view dirty and no change-detection pass would follow.
999
+ */
1000
+ setActiveChain(from, active) {
1001
+ let changed = false;
1002
+ for (let node = from; node; node = node.parent) {
1003
+ if (node.active === active)
1004
+ continue;
1005
+ node.active = active;
1006
+ this.markProps(node);
1007
+ changed = true;
1008
+ }
1009
+ return changed;
1010
+ }
1011
+ /**
1012
+ * Custom properties the device knows and the stylesheet cannot, once they change.
1013
+ *
1014
+ * Merged rather than replaced: the insets come from a view and the hairline from the device, so
1015
+ * a caller owns the properties it names and nothing else. Each owner writes all of its own at
1016
+ * once - four insets together, never one - so nothing is left over from a previous rotation.
1017
+ */
1018
+ updateTokens(next) {
1019
+ this.styles.setRootTokens({ ...this.styles.rootTokens, ...next });
1020
+ this.root.subtreeDirty = true;
1021
+ this.commit();
1022
+ }
1023
+ /**
1024
+ * The system text size changed: measure every text and text input again, and commit.
1025
+ *
1026
+ * React Native's surface re-measures its text when the content size category changes, on a
1027
+ * commit of its own. The next commit from here is built from the handles the engine holds,
1028
+ * which are the nodes from before that, laid out at the old size, and Yoga keeps a clean node's
1029
+ * layout: every text whose content had not changed kept its old box, with its glyphs drawn at
1030
+ * the new size inside it, clipped. So each one is committed again, cloned with its children,
1031
+ * which is what makes Fabric dirty a measured node and measure it at the size now in effect.
1032
+ */
1033
+ remeasureText() {
1034
+ const visit = (node) => {
1035
+ for (const child of node.children) {
1036
+ if (child.kind !== 'element')
1037
+ continue;
1038
+ if (child.committed && MEASURED_VIEWS.has(viewNameOf(child))) {
1039
+ child.remeasure = true;
1040
+ this.markProps(child, false);
1041
+ }
1042
+ visit(child);
1043
+ }
1044
+ };
1045
+ visit(this.root);
1046
+ this.commit();
1047
+ }
1048
+ /**
1049
+ * Put the cursor in a text input; bring anything else on screen, in the scroll view it is in.
1050
+ * A form sends the user to the first field that needs fixing this way, and a toggle or a picker
1051
+ * has no cursor to take, so it is scrolled to instead.
1052
+ */
1053
+ focus(node) {
1054
+ if (TEXT_INPUTS.has(viewNameOf(node)))
1055
+ this.dispatchCommand(node, 'focus');
1056
+ else
1057
+ this.reveal(node);
1058
+ }
1059
+ /**
1060
+ * Scroll the nearest scroll view the node is in by as little as brings the node fully on
1061
+ * screen, with `margin` to spare - UIKit's `scrollRectToVisible`. `visibleBottom` is where in
1062
+ * the window the visible area ends when something covers the bottom of the scroll view: the
1063
+ * keyboard's top edge, for a field the keyboard would otherwise hide.
1064
+ *
1065
+ * Measured in the window, all of it, so the scroll view's offset needs no tracking: it is how
1066
+ * far its content's top edge is above its own.
1067
+ */
1068
+ reveal(node, options = {}) {
1069
+ // The nearest scroll view that scrolls up and down: a sideways carousel on a page is not
1070
+ // what moves a field clear of the bottom of the screen.
1071
+ let scroll = node.parent;
1072
+ while (scroll &&
1073
+ !(SCROLL_VIEWS.has(viewNameOf(scroll)) && scroll.props['horizontal'] !== true)) {
1074
+ scroll = scroll.parent;
1075
+ }
1076
+ const content = scroll && this.visibleChildren(scroll)[0];
1077
+ if (!scroll || !content)
1078
+ return;
1079
+ const margin = options.margin ?? 16;
1080
+ this.measure(scroll, (view) => {
1081
+ this.measure(content, (inner) => {
1082
+ this.measure(node, (target) => {
1083
+ const offset = view.y - inner.y;
1084
+ const top = view.y + margin;
1085
+ const bottom = Math.min(view.y + view.height, options.visibleBottom ?? Infinity) - margin;
1086
+ let by = 0;
1087
+ if (target.y + target.height > bottom)
1088
+ by = target.y + target.height - bottom;
1089
+ else if (target.y < top)
1090
+ by = target.y - top;
1091
+ if (by !== 0)
1092
+ this.dispatchCommand(scroll, 'scrollTo', [0, Math.max(0, offset + by), true]);
1093
+ });
1094
+ });
1095
+ });
1096
+ }
1097
+ /**
1098
+ * Commit one measured node again, cloned with its children, so Fabric measures it once more.
1099
+ *
1100
+ * A text input is measured from its native state, and a text set from JavaScript reaches that
1101
+ * state only in the layout after the measurement, so the commit carrying the text sizes the
1102
+ * field for the text it had before. This is the commit that sizes it for the new one: one of
1103
+ * the exceptions to a commit per pass, because waiting for the next pass would leave the field
1104
+ * the wrong height until something else changed.
1105
+ */
1106
+ remeasure(node) {
1107
+ if (!node.committed)
1108
+ return;
1109
+ node.remeasure = true;
1110
+ this.markProps(node, false);
1111
+ this.commit();
1112
+ }
1113
+ /** The window's size, from the conditions media queries use. Zero until the platform says. */
1114
+ get viewport() {
1115
+ return this.viewportSize;
1116
+ }
1117
+ viewportSize;
1118
+ updateConditions(next) {
1119
+ this.viewportSize = { width: next.width, height: next.height };
1120
+ this.styles.setConditions(next);
1121
+ this.root.subtreeDirty = true;
1122
+ // Committed here rather than left to the next change-detection pass, because there may not
1123
+ // be one: no view is dirty, so a tick does no work and never reaches the renderer at all.
1124
+ this.commit();
1125
+ }
1126
+ // --- mutation API -----------------------------------------------------
1127
+ createElement(name, sheet = null) {
1128
+ const node = new RetainedNode('element', name, this);
1129
+ node.sheet = sheet;
1130
+ if (sheet?.structural)
1131
+ this.structuralSheets = true;
1132
+ if (HOISTS[name])
1133
+ this.hoisted.add(node);
1134
+ return node;
1135
+ }
1136
+ addClass(node, name) {
1137
+ (node.classes ??= new Set()).add(name);
1138
+ this.markProps(node);
1139
+ }
1140
+ removeClass(node, name) {
1141
+ node.classes?.delete(name);
1142
+ this.markProps(node);
1143
+ }
1144
+ /** `class="a b"` from a template. Replaces the set rather than adding to it. */
1145
+ setClasses(node, value) {
1146
+ const names = value.split(/\s+/).filter(Boolean);
1147
+ node.classes = names.length ? new Set(names) : null;
1148
+ this.markProps(node);
1149
+ }
1150
+ /** Angular's `@if`/`@for`/`ViewContainerRef` markers. Ordered, never committed. */
1151
+ createAnchor() {
1152
+ return new RetainedNode('anchor', '#anchor', this);
1153
+ }
1154
+ createText(value) {
1155
+ const node = new RetainedNode('text', '#text', this);
1156
+ node.text = value;
1157
+ return node;
1158
+ }
1159
+ appendChild(parent, child) {
1160
+ this.detach(child);
1161
+ child.parent = parent;
1162
+ parent.children.push(child);
1163
+ if (child.dormantHoists)
1164
+ this.wakeHoists(child);
1165
+ this.markStructure(parent);
1166
+ }
1167
+ insertBefore(parent, child, ref) {
1168
+ this.detach(child);
1169
+ child.parent = parent;
1170
+ const at = ref ? parent.children.indexOf(ref) : -1;
1171
+ if (at < 0)
1172
+ parent.children.push(child);
1173
+ else
1174
+ parent.children.splice(at, 0, child);
1175
+ if (child.dormantHoists)
1176
+ this.wakeHoists(child);
1177
+ this.markStructure(parent);
1178
+ }
1179
+ removeChild(parent, child) {
1180
+ const target = parent ?? child.parent;
1181
+ if (!target)
1182
+ return;
1183
+ const at = target.children.indexOf(child);
1184
+ if (at < 0)
1185
+ return;
1186
+ target.children.splice(at, 1);
1187
+ child.parent = null;
1188
+ this.removedSinceCommit = true;
1189
+ this.markStructure(target);
1190
+ }
1191
+ /** A subtree that went out of the tree is back in: its hoisted nodes commit again. */
1192
+ wakeHoists(node) {
1193
+ for (const hoisted of node.dormantHoists)
1194
+ this.hoisted.add(hoisted);
1195
+ node.dormantHoists = null;
1196
+ }
1197
+ /**
1198
+ * Let go of everything the engine holds for nodes that have left the tree.
1199
+ *
1200
+ * Removal is the only signal a destroyed subtree reliably sends. Angular calls `destroyNode` for
1201
+ * the top-level nodes of the view it destroys and not for anything inside a child component's
1202
+ * view, so a screen's own template is never told it is gone. A reference the engine keeps to any
1203
+ * one node of such a subtree keeps all of it, because a node reaches every other node of its
1204
+ * subtree through its parent and children, and each of those holds its committed Fabric handle,
1205
+ * which keeps its native shadow node alive. A screen with a header was retained in full that way,
1206
+ * every native node of it, for as long as the app ran.
1207
+ *
1208
+ * Removal is not destruction, though: a detached screen can be put back, and must come back
1209
+ * whole. So a hoisted node moves to the root of the subtree it left with, which is garbage along
1210
+ * with it if the subtree never returns, and is handed back when it does. An animation on a node
1211
+ * out of the tree stops rather than running a frame loop for something nobody can see, and
1212
+ * starts again if the node returns.
1213
+ *
1214
+ * Once per commit, and only after a removal: the walks are the depth of a handful of nodes.
1215
+ */
1216
+ releaseDetached() {
1217
+ this.removedSinceCommit = false;
1218
+ for (const node of this.hoisted) {
1219
+ const top = this.topOf(node);
1220
+ if (top === this.root)
1221
+ continue;
1222
+ this.hoisted.delete(node);
1223
+ (top.dormantHoists ??= []).push(node);
1224
+ }
1225
+ for (const node of this.playing) {
1226
+ if (this.topOf(node) === this.root)
1227
+ continue;
1228
+ this.playing.delete(node);
1229
+ node.playing = undefined;
1230
+ this.markProps(node, false);
1231
+ }
1232
+ for (const nodes of this.scrollTimelines.values()) {
1233
+ for (const node of nodes) {
1234
+ if (this.topOf(node) === this.root)
1235
+ continue;
1236
+ this.stopScrolled(node);
1237
+ this.markProps(node, false);
1238
+ }
1239
+ }
1240
+ if (this.focusedNode && this.topOf(this.focusedNode) !== this.root)
1241
+ this.focusedNode = null;
1242
+ }
1243
+ /** The root of the tree a node is in: the engine's root while it is attached. */
1244
+ topOf(node) {
1245
+ let top = node;
1246
+ while (top.parent)
1247
+ top = top.parent;
1248
+ return top;
1249
+ }
1250
+ /**
1251
+ * Angular calls this per node on view destroy, but only when `Renderer2.destroyNode` is
1252
+ * non-null. Without it the listener sets on discarded nodes stay reachable from the
1253
+ * closures Angular hands us, which is a slow leak on any screen that churns views.
1254
+ */
1255
+ destroyNode(node) {
1256
+ node.listeners?.clear();
1257
+ node.listeners = null;
1258
+ node.committed = null;
1259
+ node.transitions = undefined;
1260
+ node.playing = undefined;
1261
+ this.stopScrolled(node);
1262
+ this.running.delete(node);
1263
+ this.playing.delete(node);
1264
+ this.hoisted.delete(node);
1265
+ node.kept = undefined;
1266
+ // A field popped with the keyboard up never sends its blur.
1267
+ if (this.focusedNode === node)
1268
+ this.focusedNode = null;
1269
+ }
1270
+ parentNode(node) {
1271
+ return node.parent;
1272
+ }
1273
+ nextSibling(node) {
1274
+ const siblings = node.parent?.children;
1275
+ if (!siblings)
1276
+ return null;
1277
+ return siblings[siblings.indexOf(node) + 1] ?? null;
1278
+ }
1279
+ setProp(node, key, value) {
1280
+ if (value === undefined || value === null) {
1281
+ // Nothing to remove, so nothing changed. Worth its own branch because it is the common
1282
+ // case, not a rare one: a host primitive carries a host binding for every prop React Native
1283
+ // reads, so every element on screen writes some thirty of these on every pass, and the
1284
+ // work being skipped is a walk to the root marking ancestors dirty.
1285
+ if (!(key in node.props))
1286
+ return;
1287
+ delete node.props[key];
1288
+ }
1289
+ else {
1290
+ if (node.props[key] === value)
1291
+ return;
1292
+ node.props[key] = value;
1293
+ }
1294
+ // Inline style is applied after the cascade and inherited by nothing, so it cannot change
1295
+ // what any node matches or inherits. Every other prop can: `[disabled]` is a selector. An
1296
+ // inline `direction` is the exception, because the paragraphs below it align by it.
1297
+ this.markProps(node, key !== 'style' || this.noteInlineDirection(node));
1298
+ }
1299
+ /** Whether a node's inline style sets `direction`, remembering that one has. */
1300
+ noteInlineDirection(node) {
1301
+ const style = node.props['style'];
1302
+ if (!style || (typeof style === 'object' && !Array.isArray(style) && !('direction' in style))) {
1303
+ return false;
1304
+ }
1305
+ if (flattenStyle(style, {})['direction'] === undefined)
1306
+ return false;
1307
+ this.inlineDirection = true;
1308
+ return true;
1309
+ }
1310
+ /**
1311
+ * The style object a node already holds has been changed in place.
1312
+ *
1313
+ * Angular's styling instructions set one property at a time, so a style of ten properties would
1314
+ * otherwise mean ten copies of a growing object per element. The adapter owns the object it put
1315
+ * there and may write into it; this is how it says so, since nothing about the object's identity
1316
+ * has changed for the props diff to notice.
1317
+ */
1318
+ styleChanged(node) {
1319
+ this.markProps(node, this.noteInlineDirection(node));
1320
+ }
1321
+ /**
1322
+ * Set a custom property on a node, `--tint` from `[style.--tint]`, or remove it with `null`.
1323
+ *
1324
+ * Not a prop: Fabric has no such thing, and would be sent one. It is a token for the cascade,
1325
+ * converted from the value here because nothing built it ahead of time (`inline-token.ts`), and
1326
+ * changing it re-resolves the node and so everything under it.
1327
+ */
1328
+ setCustomProperty(node, name, value) {
1329
+ const token = value === null || value === undefined ? undefined : tokenFromValue(value);
1330
+ const current = node.customProperties;
1331
+ if (!token) {
1332
+ if (!current || !(name in current))
1333
+ return;
1334
+ const { [name]: _removed, ...rest } = current;
1335
+ node.customProperties = Object.keys(rest).length ? rest : null;
1336
+ }
1337
+ else {
1338
+ node.customProperties = { ...current, [name]: token };
1339
+ }
1340
+ this.markProps(node);
1341
+ }
1342
+ setText(node, value) {
1343
+ node.text = value;
1344
+ this.markProps(node);
1345
+ }
1346
+ setEventListener(node, topLevelType, fn) {
1347
+ this.registerEventHandler();
1348
+ const optIn = EVENT_OPT_IN_PROPS[topLevelType] ?? VIEW_EVENT_OPT_IN_PROPS[viewNameOf(node)]?.[topLevelType];
1349
+ if (optIn && node.props[optIn] !== true)
1350
+ this.setProp(node, optIn, true);
1351
+ const listeners = (node.listeners ??= new Map());
1352
+ let set = listeners.get(topLevelType);
1353
+ if (!set)
1354
+ listeners.set(topLevelType, (set = new Set()));
1355
+ set.add(fn);
1356
+ return () => set.delete(fn);
1357
+ }
1358
+ // --- dirty marking ----------------------------------------------------
1359
+ markProps(node, affectsStyle = true) {
1360
+ node.propsDirty = true;
1361
+ // Classes, `nativeID` and anything else a selector can read live in props, so any of them
1362
+ // changing can change what this node matches, and through it what its whole subtree does.
1363
+ // Marking only when it can is what keeps an animated `[style]` from re-cascading everything
1364
+ // beneath it on every frame.
1365
+ if (affectsStyle) {
1366
+ node.styleDirty = true;
1367
+ // `.peer:focus ~ .label`: what a later sibling matches can hang on this node's classes and
1368
+ // state, though nothing about the sibling moved. Only sheets that ask about siblings pay.
1369
+ if (this.structuralSheets)
1370
+ this.markLaterSiblings(node);
1371
+ }
1372
+ // The root has no parent to carry the mark, so it carries its own: `.dark` on the root alone
1373
+ // restyles everything beneath it.
1374
+ this.markPath(node.parent ?? node);
1375
+ }
1376
+ markLaterSiblings(node) {
1377
+ const siblings = node.parent?.children;
1378
+ if (!siblings)
1379
+ return;
1380
+ for (let i = siblings.indexOf(node) + 1; i < siblings.length; i++) {
1381
+ siblings[i].styleDirty = true;
1382
+ }
1383
+ }
1384
+ markStructure(node) {
1385
+ node.structureDirty = true;
1386
+ // A child list that moved changes what its members match, though nothing about them did:
1387
+ // the old last row is no longer the last. Only sheets that ask about position pay for this.
1388
+ if (this.structuralSheets) {
1389
+ node.styleDirty = true;
1390
+ for (const child of node.children)
1391
+ child.styleDirty = true;
1392
+ }
1393
+ this.markPath(node.parent);
1394
+ }
1395
+ /** Walk up until we hit a node already known to have a dirty subtree. */
1396
+ markPath(node) {
1397
+ const wasClean = !this.root.structureDirty && !this.root.subtreeDirty;
1398
+ for (let current = node; current && !current.subtreeDirty; current = current.parent) {
1399
+ current.subtreeDirty = true;
1400
+ }
1401
+ // The tree has just gone from clean to dirty. Inside a change-detection pass that means
1402
+ // nothing, because the pass ends in a commit; outside one it means the change has no commit
1403
+ // coming, and the host has to arrange a frame.
1404
+ if (wasClean && this.root.subtreeDirty)
1405
+ this.onDirty?.();
1406
+ }
1407
+ isClean(node) {
1408
+ return (node.committed !== null && !node.propsDirty && !node.structureDirty && !node.subtreeDirty);
1409
+ }
1410
+ // --- commit -----------------------------------------------------------
1411
+ /**
1412
+ * Incremental: clone only what changed and reuse untouched subtrees by reference. A full
1413
+ * rebuild would mint fresh shadow nodes for every node on every tick, which drops scroll
1414
+ * offset, text cursor and keyboard focus. That is a correctness requirement, not an
1415
+ * optimisation.
1416
+ */
1417
+ commit() {
1418
+ if (this.removedSinceCommit)
1419
+ this.releaseDetached();
1420
+ // The root is never reconciled itself, so it has no `committed` record and `isClean` would
1421
+ // always say dirty. Ask the flags directly, or every change-detection pass ends in a
1422
+ // completeRoot that changes nothing: during a fling that is one wasted native commit per
1423
+ // frame, since any listener firing marks the view for refresh.
1424
+ this.settleCreated();
1425
+ if (!this.root.structureDirty && !this.root.subtreeDirty)
1426
+ return false;
1427
+ const started = now();
1428
+ // One epoch per commit. Node props do not change while a commit runs, so every node can be
1429
+ // resolved once and shared with each of its descendants for the length of this walk.
1430
+ this.styleEpoch++;
1431
+ const set = this.fabric.createChildSet(this.rootTag);
1432
+ for (const child of this.visibleChildren(this.root)) {
1433
+ this.fabric.appendChildToSet(set, this.reconcileUnder(this.root, child));
1434
+ }
1435
+ this.clearFlags(this.root);
1436
+ this.fabric.completeRoot(this.rootTag, set);
1437
+ const elapsed = now() - started;
1438
+ this.stats.commits++;
1439
+ this.stats.lastCommitMs = elapsed;
1440
+ if (this.stats.commits === 1) {
1441
+ this.stats.firstCommitMs = elapsed;
1442
+ }
1443
+ else {
1444
+ if (elapsed > this.stats.worstCommitMs)
1445
+ this.stats.worstCommitMs = elapsed;
1446
+ if (elapsed > FRAME_BUDGET_MS)
1447
+ this.stats.slowCommits++;
1448
+ }
1449
+ this.flushTransitionEvents();
1450
+ this.scrollDriver?.afterCommit();
1451
+ return true;
1452
+ }
1453
+ scrollDriver;
1454
+ /** Whether native can move a view by a scroll with no JavaScript in between. */
1455
+ get drivesScroll() {
1456
+ return this.scrollDriver !== null;
1457
+ }
1458
+ /** See `HostEngine.driveByScroll`. The connection waits for a commit of both views. */
1459
+ driveByScroll(view, scroll, axis, range, statics = []) {
1460
+ const property = axis === 'x' ? 'translateX' : 'translateY';
1461
+ return this.driveByEvent(view, scroll, scrollFeed(axis), property, range, statics);
1462
+ }
1463
+ /** See `HostEngine.driveByEvent`. */
1464
+ driveByEvent(view, source, feed, property, range, statics = [], shift = null) {
1465
+ if (!this.scrollDriver)
1466
+ return null;
1467
+ // As Animated does: Fabric flattens a view that only lays out, and then there is no native
1468
+ // view for the animation to move.
1469
+ this.setProp(view, 'collapsable', false);
1470
+ return this.scrollDriver.drive(view, source, feed, property, range, statics, (node) => node.committed?.tag ?? null, shift);
1471
+ }
1472
+ /**
1473
+ * `transitionstart` and `transitionend`, for anything listening.
1474
+ *
1475
+ * Angular's `animate.enter` and `animate.leave` are built on these: they add a class, wait for
1476
+ * the transition it starts, and clean up when it ends. Nothing native emits them - there is no
1477
+ * CSS animation in React Native to emit them - so the engine that runs the transition is what
1478
+ * says when one began and ended.
1479
+ */
1480
+ /** Created since the last commit and waiting to settle. See `HostEngine.settle`. */
1481
+ unsettled = [];
1482
+ settle(target) {
1483
+ this.unsettled.push(target);
1484
+ }
1485
+ /**
1486
+ * Let every element created since the last commit write what its inputs say. One that was
1487
+ * checked already has, and answers at once; one whose view is detached writes its props here,
1488
+ * so the commit that first mounts it carries them.
1489
+ */
1490
+ settleCreated() {
1491
+ if (this.unsettled.length === 0)
1492
+ return;
1493
+ const created = this.unsettled;
1494
+ this.unsettled = [];
1495
+ for (const target of created)
1496
+ target.settleProps();
1497
+ }
1498
+ transitionEvents = [];
1499
+ emitTransition(node, type, property) {
1500
+ if (!node.listeners?.has(type))
1501
+ return;
1502
+ this.transitionEvents.push({ node, type, property });
1503
+ }
1504
+ /**
1505
+ * After the commit, never during it: a listener is free to add a class or drop an element, and
1506
+ * a tree that changes halfway through being walked is the one thing a commit cannot survive.
1507
+ */
1508
+ flushTransitionEvents() {
1509
+ if (this.transitionEvents.length === 0)
1510
+ return;
1511
+ for (const { node, type, property } of this.transitionEvents.splice(0)) {
1512
+ for (const listener of [...(node.listeners?.get(type) ?? [])]) {
1513
+ listener(animationEvent(type, node, property));
1514
+ }
1515
+ }
1516
+ }
1517
+ /** Anchors take part in sibling ordering but never reach Fabric. */
1518
+ visibleChildren(node) {
1519
+ return node.children.filter((child) => child.kind !== 'anchor');
1520
+ }
1521
+ /**
1522
+ * Everything a node renders with, unprocessed. Precedence, weakest first: native defaults,
1523
+ * matched CSS, explicit props, inline style, a component's `styleOverride`. Inline wins over CSS for the same reason it does
1524
+ * on the web.
1525
+ *
1526
+ * Raw, because this is what the next commit diffs against. Colours and asset ids are converted
1527
+ * on the way out instead (`processed`), and only for the keys that changed: the converters
1528
+ * return fresh objects, so diffing their output would re-send every image and shadow on every
1529
+ * unrelated change.
1530
+ */
1531
+ mergeProps(node, viewName) {
1532
+ if (node.kind === 'text')
1533
+ return { text: paragraphText(node) };
1534
+ if (this.dev)
1535
+ this.checkProps(node);
1536
+ this.registerKeyframes(node.sheet);
1537
+ this.registerKeyframes(node.hostSheet);
1538
+ const props = { ...DEFAULT_PROPS[viewName] };
1539
+ Object.assign(props, this.styles.resolve(node, this.styleEpoch).style);
1540
+ for (const key of Object.keys(node.props)) {
1541
+ // No native prop has a hyphen. `data-*` and `aria-*` attributes stay on the node for
1542
+ // selectors to match, and the components package maps `aria-*` to what native reads.
1543
+ if (key !== 'style' &&
1544
+ key !== INTRINSIC_SIZE &&
1545
+ key !== STYLE_OVERRIDE &&
1546
+ !key.includes('-')) {
1547
+ props[key] = node.props[key];
1548
+ }
1549
+ }
1550
+ const style = flattenStyle(node.props['style'], props);
1551
+ const intrinsic = node.props[INTRINSIC_SIZE];
1552
+ if (intrinsic)
1553
+ applyIntrinsicSize(style, intrinsic);
1554
+ flattenStyle(node.props[STYLE_OVERRIDE], style);
1555
+ if (viewName === PARAGRAPH)
1556
+ alignText(style, this.directionOf(node, style));
1557
+ return composeTransform(node, this.animated(node, this.transitioned(node, style)));
1558
+ }
1559
+ /** Set once any node has had a `direction` in its inline style. See `directionOf`. */
1560
+ inlineDirection = false;
1561
+ /**
1562
+ * The direction a paragraph is laid out in, where the app wrote one: its own, or the nearest
1563
+ * ancestor's. Undefined when nothing above it says, which leaves the platform's own.
1564
+ *
1565
+ * A stylesheet's `direction` is inherited through the cascade, so it is already in `props`. An
1566
+ * inline one is not, and is found by walking up, which only happens once some node has had one.
1567
+ */
1568
+ directionOf(node, props) {
1569
+ const own = textDirection(props['direction']);
1570
+ if (!this.inlineDirection)
1571
+ return own;
1572
+ for (let at = node; at; at = at.parent) {
1573
+ const inline = textDirection(flattenStyle(at.props['style'], {})['direction']);
1574
+ if (inline)
1575
+ return inline;
1576
+ const cascaded = textDirection(at.styleCache?.style['direction']);
1577
+ if (cascaded && cascaded !== textDirection(at.parent?.styleCache?.style['direction'])) {
1578
+ return cascaded;
1579
+ }
1580
+ }
1581
+ return own;
1582
+ }
1583
+ /**
1584
+ * Hold back what a transition is meant to ease into.
1585
+ *
1586
+ * Called with everything the node would otherwise commit with, so a property is compared
1587
+ * against the last value aimed at rather than the last one painted: a transition redirected
1588
+ * halfway carries on from where it had got to instead of snapping.
1589
+ *
1590
+ * A property seen for the first time is recorded and left alone. That is CSS's own rule, and
1591
+ * without it every element would fade in from whatever the previous value happened to be.
1592
+ */
1593
+ /** Take a sheet's `@keyframes` into the registry. Cheap and idempotent; sheets are few. */
1594
+ registerKeyframes(sheet) {
1595
+ if (!sheet?.keyframes || this.knownSheets.has(sheet))
1596
+ return;
1597
+ this.knownSheets.add(sheet);
1598
+ for (const name of Object.keys(sheet.keyframes)) {
1599
+ this.keyframes.set(name, sheet.keyframes[name]);
1600
+ }
1601
+ }
1602
+ knownSheets = new WeakSet();
1603
+ /**
1604
+ * Start, advance or stop the `@keyframes` animation a node's style asks for.
1605
+ *
1606
+ * Unlike a transition this does not need anything to have changed: an animation plays from its
1607
+ * own frames, which is what lets an element animate in without differing from anything.
1608
+ */
1609
+ animated(node, props) {
1610
+ const spec = animationOf(props);
1611
+ // One played by a scroll that is no longer asked for, whatever is asked for instead.
1612
+ if (node.scrolled && !spec?.timeline)
1613
+ this.stopScrolled(node);
1614
+ if (!spec) {
1615
+ if (node.playing) {
1616
+ node.playing = undefined;
1617
+ this.playing.delete(node);
1618
+ }
1619
+ return props;
1620
+ }
1621
+ const frames = this.keyframes.get(spec.name);
1622
+ if (!frames) {
1623
+ if (this.dev)
1624
+ this.reportMissingKeyframes(spec.name);
1625
+ return props;
1626
+ }
1627
+ if (spec.timeline)
1628
+ return this.scrollAnimated(node, spec, frames, props);
1629
+ this.startPlaying(node, spec, frames, props);
1630
+ return Object.assign(props, node.playing?.values ?? {});
1631
+ }
1632
+ /** Start the clock on an animation, unless the node is already playing this one. */
1633
+ startPlaying(node, spec, frames, props) {
1634
+ const current = node.playing;
1635
+ if (!current || !sameAnimation(current.spec, spec)) {
1636
+ const started = {
1637
+ spec,
1638
+ tracks: tracksOf(frames, props),
1639
+ start: this.now(),
1640
+ values: {},
1641
+ done: false,
1642
+ };
1643
+ // Sampled straight away, so the very commit that starts the animation paints its first
1644
+ // frame. Otherwise an element appears in its resting style for one frame and then jumps,
1645
+ // which is the flash this whole mechanism exists to avoid.
1646
+ //
1647
+ // A delayed animation therefore holds its first frame through the delay rather than the
1648
+ // resting style: `animation-fill-mode: backwards` behaviour, always. It is what an entrance
1649
+ // wants, and the alternative is the flash again.
1650
+ started.values = sample(started, this.now()).values;
1651
+ node.playing = started;
1652
+ if (spec.paused)
1653
+ started.pausedAt = this.now();
1654
+ else
1655
+ this.playing.add(node);
1656
+ this.emitTransition(node, 'topAnimationstart', spec.name);
1657
+ return;
1658
+ }
1659
+ this.playOrPause(node, current, spec);
1660
+ }
1661
+ /**
1662
+ * `animation-play-state` changed on an animation already playing: pausing holds the frame it
1663
+ * has reached, and running again carries on from there, the paused time left out of its clock.
1664
+ * Neither starts it over, as a change of play state does not in CSS.
1665
+ */
1666
+ playOrPause(node, current, spec) {
1667
+ current.spec = spec;
1668
+ const now = this.now();
1669
+ if (spec.paused && current.pausedAt === undefined && !current.done) {
1670
+ current.pausedAt = now;
1671
+ this.playing.delete(node);
1672
+ }
1673
+ else if (!spec.paused && current.pausedAt !== undefined) {
1674
+ current.start += now - current.pausedAt;
1675
+ current.pausedAt = undefined;
1676
+ this.playing.add(node);
1677
+ }
1678
+ }
1679
+ /** Every node playing a scroll-driven animation, by the scroll view that plays it. */
1680
+ scrollTimelines = new Map();
1681
+ /** How far each such scroll view scrolls, along each axis, once one of its events has said. */
1682
+ scrollExtents = new Map();
1683
+ /**
1684
+ * An animation played by the nearest scroll view's offset: `animation-timeline: scroll()`.
1685
+ *
1686
+ * Its keyframes are laid along the offset and handed to native, which plays them as the view
1687
+ * scrolls, with no JavaScript between (`scroll-animation.ts`, `NativeScrollDriver.animate`).
1688
+ * The frame at the offset the view starts at is committed once and then left alone: a prop that
1689
+ * changed on a later commit would reset what native is animating.
1690
+ */
1691
+ scrollAnimated(node, spec, frames, props) {
1692
+ const current = node.scrolled;
1693
+ if (current && sameAnimation(current.spec, spec))
1694
+ return Object.assign(props, current.first);
1695
+ if (current)
1696
+ this.stopScrolled(node);
1697
+ if (node.playing) {
1698
+ node.playing = undefined;
1699
+ this.playing.delete(node);
1700
+ }
1701
+ const tracks = tracksOf(frames, props);
1702
+ const source = this.scrollSourceOf(node);
1703
+ const extent = source ? (this.scrollExtents.get(source)?.[spec.timeline] ?? null) : null;
1704
+ const range = rangeOf(spec, extent);
1705
+ const first = firstFrame(tracks, spec, range);
1706
+ const resting = { ...props };
1707
+ if (!source && this.dev)
1708
+ this.reportUnscrolled(spec.name);
1709
+ const drive = source ? this.driveScrolled(node, source, spec, tracks, resting, range) : null;
1710
+ // As Animated does: Fabric flattens a view that only lays out, and then there is no native
1711
+ // view for the animation to move. Committed with the first frame, so it stays put.
1712
+ if (drive)
1713
+ first['collapsable'] = false;
1714
+ node.scrolled = { spec, tracks, resting, first, source, drive };
1715
+ return Object.assign(props, first);
1716
+ }
1717
+ /** Hand a scroll-driven animation's channels to native, fed by `source`'s scroll events. */
1718
+ driveScrolled(node, source, spec, tracks, resting, range) {
1719
+ if (!this.scrollDriver)
1720
+ return null;
1721
+ const { channels, held } = scrollChannels(tracks, spec, resting, range);
1722
+ if (held.length && this.dev)
1723
+ this.reportHeld(spec.name, held);
1724
+ let playing = this.scrollTimelines.get(source);
1725
+ if (!playing)
1726
+ this.scrollTimelines.set(source, (playing = new Set()));
1727
+ playing.add(node);
1728
+ return this.scrollDriver.animate(node, source, scrollFeed(spec.timeline), channels, (one) => one.committed?.tag ?? null);
1729
+ }
1730
+ /** The scroll view a scroll-driven animation on `node` follows: the nearest one it is inside. */
1731
+ scrollSourceOf(node) {
1732
+ for (let up = node.parent; up; up = up.parent) {
1733
+ const name = viewNameOf(up);
1734
+ if (name === 'ScrollView' || name === 'AndroidHorizontalScrollView')
1735
+ return up;
1736
+ }
1737
+ return null;
1738
+ }
1739
+ stopScrolled(node) {
1740
+ const scrolled = node.scrolled;
1741
+ if (!scrolled)
1742
+ return;
1743
+ node.scrolled = undefined;
1744
+ scrolled.drive?.stop();
1745
+ if (scrolled.source)
1746
+ this.scrollTimelines.get(scrolled.source)?.delete(node);
1747
+ }
1748
+ /**
1749
+ * A scroll view has said how far it scrolls. Every animation on it with a range that depends on
1750
+ * that - none written, or a percentage - is laid out again along the new extent.
1751
+ */
1752
+ measuredScroll(source, event) {
1753
+ const { contentSize, layoutMeasurement } = (event ?? {});
1754
+ if (!contentSize || !layoutMeasurement)
1755
+ return;
1756
+ const extent = {
1757
+ x: Math.max(0, contentSize.width - layoutMeasurement.width),
1758
+ y: Math.max(0, contentSize.height - layoutMeasurement.height),
1759
+ };
1760
+ const known = this.scrollExtents.get(source);
1761
+ if (known && known.x === extent.x && known.y === extent.y)
1762
+ return;
1763
+ this.scrollExtents.set(source, extent);
1764
+ for (const node of this.scrollTimelines.get(source) ?? []) {
1765
+ const scrolled = node.scrolled;
1766
+ if (!scrolled?.drive)
1767
+ continue;
1768
+ const range = rangeOf(scrolled.spec, extent[scrolled.spec.timeline]);
1769
+ scrolled.drive.update(scrollChannels(scrolled.tracks, scrolled.spec, scrolled.resting, range).channels);
1770
+ }
1771
+ }
1772
+ reportUnscrolled(name) {
1773
+ if (this.reported.has(`scroll ${name}`))
1774
+ return;
1775
+ this.reported.add(`scroll ${name}`);
1776
+ console.error(`[angular-native] '${name}' plays by animation-timeline: scroll(), but the element is not ` +
1777
+ `inside a scroll view, so nothing moves it. It shows its first frame.`);
1778
+ }
1779
+ reportHeld(name, properties) {
1780
+ if (this.reported.has(`held ${name}`))
1781
+ return;
1782
+ this.reported.add(`held ${name}`);
1783
+ console.error(`[angular-native] '${name}' plays by a scroll, which native drives for opacity and ` +
1784
+ `transforms only. ${properties.join(', ')} holds its first frame.`);
1785
+ }
1786
+ reportMissingKeyframes(name) {
1787
+ if (this.reported.has(`@keyframes ${name}`))
1788
+ return;
1789
+ this.reported.add(`@keyframes ${name}`);
1790
+ console.error(`[angular-native] no @keyframes named '${name}'. Nothing animates, and the element ` +
1791
+ `renders with its resting style. Keyframes are global across every stylesheet the app ` +
1792
+ `has loaded, so the block has to exist somewhere by the time the animation starts.`);
1793
+ }
1794
+ transitioned(node, props) {
1795
+ const spec = transitionSpec(props);
1796
+ if (!spec && !node.transitions)
1797
+ return props;
1798
+ const state = (node.transitions ??= new Map());
1799
+ const now = this.now();
1800
+ // The properties a transition *names*, even where the node has none yet. A property is only
1801
+ // transitioned once it has been seen settling, and one that does not exist is never seen - so
1802
+ // `transition: transform` with no transform until a class adds one would arrive instantly,
1803
+ // which is the commonest way to write one. `all` names nothing, so it keeps the old rule.
1804
+ const named = spec ? Object.keys(spec).filter((key) => key !== 'all') : [];
1805
+ for (const key of new Set([...Object.keys(props), ...named])) {
1806
+ if (step(state, key, props, spec?.[key] ?? spec?.['all'], now)) {
1807
+ this.running.add(node);
1808
+ this.emitTransition(node, 'topTransitionstart', key);
1809
+ }
1810
+ }
1811
+ if (state.size === 0)
1812
+ node.transitions = undefined;
1813
+ return props;
1814
+ }
1815
+ /** One `@keyframes` player, one frame on. */
1816
+ advancePlayer(node, now) {
1817
+ const running = node.playing;
1818
+ if (!running) {
1819
+ this.playing.delete(node);
1820
+ return;
1821
+ }
1822
+ if (running.pausedAt !== undefined) {
1823
+ this.playing.delete(node);
1824
+ return;
1825
+ }
1826
+ const { values, finished } = sample(running, now);
1827
+ // Without a forwards fill a finished animation stops holding anything, and the element falls
1828
+ // back to whatever the cascade gives it. 'backwards' fills the start only.
1829
+ const holds = running.spec.fill === 'forwards' || running.spec.fill === 'both';
1830
+ running.values = finished && !holds ? {} : values;
1831
+ this.markProps(node, false);
1832
+ if (!finished)
1833
+ return;
1834
+ running.done = true;
1835
+ this.playing.delete(node);
1836
+ this.emitTransition(node, 'topAnimationend', running.spec.name);
1837
+ }
1838
+ /**
1839
+ * Late-bound because the host that wants the callback is usually built from this engine, so
1840
+ * neither can be constructed with the other in hand.
1841
+ */
1842
+ setOnDirty(fn) {
1843
+ this.onDirty = fn;
1844
+ }
1845
+ /** Late-bound for the same reason: the app's `ErrorHandler` lives in an injector built later. */
1846
+ setOnError(fn) {
1847
+ this.onError = fn;
1848
+ }
1849
+ /** Whether any transition or animation still has frames left to run. */
1850
+ get animating() {
1851
+ return this.running.size > 0 || this.playing.size > 0;
1852
+ }
1853
+ /**
1854
+ * Whether anything has changed since the last commit.
1855
+ *
1856
+ * The frame loop reads this because a `transitionend` listener runs after its commit has
1857
+ * finished, and what it does is often to remove the element that just finished leaving. That
1858
+ * change needs a commit of its own or the view stays on screen, fully transparent, forever.
1859
+ */
1860
+ get pending() {
1861
+ return this.root.structureDirty || this.root.subtreeDirty;
1862
+ }
1863
+ /**
1864
+ * Advance every transition to `now` and mark what changed. The caller commits afterwards; on a
1865
+ * device the platform pumps this from `requestAnimationFrame` for as long as it returns true.
1866
+ */
1867
+ advanceAnimations(now = this.now()) {
1868
+ for (const node of [...this.playing])
1869
+ this.advancePlayer(node, now);
1870
+ for (const node of [...this.running]) {
1871
+ let live = false;
1872
+ for (const [key, transition] of node.transitions ?? []) {
1873
+ if (transition.done)
1874
+ continue;
1875
+ const elapsed = now - transition.start;
1876
+ // Still inside the delay: nothing to paint yet, but the transition is not over.
1877
+ if (elapsed < 0) {
1878
+ live = true;
1879
+ continue;
1880
+ }
1881
+ const progress = transition.duration <= 0 ? 1 : Math.min(1, elapsed / transition.duration);
1882
+ if (progress >= 1) {
1883
+ transition.current = transition.to;
1884
+ transition.done = true;
1885
+ this.emitTransition(node, 'topTransitionend', key);
1886
+ }
1887
+ else {
1888
+ transition.current = interpolate(transition.from, transition.to, bezier(transition.easing, progress));
1889
+ live = true;
1890
+ }
1891
+ this.markProps(node, false);
1892
+ }
1893
+ if (!live)
1894
+ this.running.delete(node);
1895
+ }
1896
+ return this.animating;
1897
+ }
1898
+ /** The host's conversions, applied to a copy. A removed prop travels as null and stays null. */
1899
+ processed(props) {
1900
+ const out = {};
1901
+ for (const key of Object.keys(props)) {
1902
+ const value = props[key];
1903
+ if (value === null || value === undefined)
1904
+ out[key] = value;
1905
+ else if (isColorProp(key))
1906
+ out[key] = this.color(value);
1907
+ // A list of sources has already been resolved by whoever built it, as RN's resolver
1908
+ // also assumes: it passes any object through and only turns a number into one.
1909
+ else if (ASSET_PROPS.has(key) && !Array.isArray(value))
1910
+ out[key] = this.resolveAssetSource(value);
1911
+ else if (NESTED_COLOR_LIST_PROPS.has(key))
1912
+ out[key] = this.processNestedColors(value);
1913
+ else if (key === 'experimental_backgroundImage')
1914
+ out[key] = this.processGradients(value);
1915
+ else if (key === 'filter')
1916
+ out[key] = this.processFilters(value);
1917
+ // A bound style's transform is still the CSS string: see `inline-transform.ts`.
1918
+ else if (key === 'transform' && typeof value === 'string')
1919
+ out[key] = transformList(value);
1920
+ else
1921
+ out[key] = value;
1922
+ }
1923
+ return out;
1924
+ }
1925
+ /** Copy a list of shadow-like objects, running each `color` through the host's converter. */
1926
+ processNestedColors(value) {
1927
+ if (!Array.isArray(value))
1928
+ return value;
1929
+ return value.map((entry) => {
1930
+ const item = entry;
1931
+ if (item?.['color'] === undefined)
1932
+ return entry;
1933
+ return { ...item, color: this.color(item['color']) };
1934
+ });
1935
+ }
1936
+ /**
1937
+ * Copy a filter list, running a drop shadow's colour through the host's converter. Android reads
1938
+ * that colour as a number and throws on a string, taking the whole surface down with it.
1939
+ */
1940
+ processFilters(value) {
1941
+ if (!Array.isArray(value))
1942
+ return value;
1943
+ return value.map((entry) => {
1944
+ const shadow = entry?.dropShadow;
1945
+ if (shadow?.['color'] === undefined)
1946
+ return entry;
1947
+ return {
1948
+ dropShadow: { ...shadow, color: this.color(shadow['color']) },
1949
+ };
1950
+ });
1951
+ }
1952
+ /**
1953
+ * Copy a list of gradients, running every stop's colour through the host's converter.
1954
+ *
1955
+ * A stop's colour is a colour like any other, but it is two levels down and nothing else looks
1956
+ * there: without this the device is handed the string `rgb(255, 0, 0)` where it wants a number,
1957
+ * and paints nothing.
1958
+ */
1959
+ processGradients(value) {
1960
+ if (!Array.isArray(value))
1961
+ return value;
1962
+ return value.map((entry) => {
1963
+ const gradient = entry;
1964
+ if (!Array.isArray(gradient?.colorStops))
1965
+ return entry;
1966
+ return {
1967
+ ...gradient,
1968
+ colorStops: gradient.colorStops.map((stop) => ({
1969
+ ...stop,
1970
+ color: this.color(stop.color),
1971
+ })),
1972
+ };
1973
+ });
1974
+ }
1975
+ clearFlags(node) {
1976
+ node.propsDirty = false;
1977
+ node.structureDirty = false;
1978
+ node.subtreeDirty = false;
1979
+ }
1980
+ reconcile(node) {
1981
+ // A clean node can still need re-rendering: if an ancestor's class changed, what this node
1982
+ // inherits changed with it, and nothing marked this node dirty. The resolver hands back the
1983
+ // very same cache object when nothing has changed, so one reference comparison settles it,
1984
+ // and a subtree whose inherited context is unmoved is skipped exactly as before.
1985
+ const style = node.kind === 'element' ? this.styles.resolve(node, this.styleEpoch) : null;
1986
+ const styleChanged = node.styleCommitted !== style;
1987
+ if (this.isClean(node) && !styleChanged)
1988
+ return node.committed.handle;
1989
+ node.styleCommitted = style;
1990
+ const childHandles = this.reconcileChildren(node, style);
1991
+ const previous = node.committed;
1992
+ const viewName = viewNameOf(node);
1993
+ this.notePresented(node, viewName);
1994
+ if (!previous)
1995
+ return this.create(node, viewName, this.mergeProps(node, viewName), childHandles);
1996
+ // `propsDirty` is precise: Angular only calls setProperty when a binding actually changed.
1997
+ // It is not sufficient on its own, though. A node whose ancestor's class changed inherits
1998
+ // something new without any binding of its own having moved, so it is not propsDirty and
1999
+ // would never re-clone. `styleChanged` is the other half of that question. An ancestor that
2000
+ // is here only because something beneath it moved has the props it had.
2001
+ const propsMoved = node.propsDirty || styleChanged;
2002
+ const props = propsMoved ? this.mergeProps(node, viewName) : previous.props;
2003
+ const propsPayload = propsMoved ? diffProps(previous.props, props) : null;
2004
+ const childrenChanged = this.childrenMoved(node, childHandles, previous.childHandles);
2005
+ let handle = previous.handle;
2006
+ if (childrenChanged || propsPayload) {
2007
+ handle = this.clone(previous.handle, propsPayload && this.processed(propsPayload), childrenChanged ? childHandles : null);
2008
+ // A clone is the same view, so it keeps the tag it was created with, not the newest tag
2009
+ // handed out, which is some other node's.
2010
+ node.committed = { handle, tag: previous.tag, props, childHandles };
2011
+ }
2012
+ this.clearFlags(node);
2013
+ return handle;
2014
+ }
2015
+ /**
2016
+ * Whether a node has to be committed with its children again.
2017
+ *
2018
+ * A child that re-cloned has a new handle, so the parent must re-clone too. This is the bubble
2019
+ * that persistent trees force, and it is why dirty marking propagates upwards. A node to be
2020
+ * measured again is cloned with its children even when they are the same ones: new children
2021
+ * is what makes Fabric dirty a measured node (`remeasureText`).
2022
+ */
2023
+ childrenMoved(node, next, previous) {
2024
+ const remeasure = node.remeasure === true;
2025
+ node.remeasure = undefined;
2026
+ return remeasure || !sameHandles(next, previous);
2027
+ }
2028
+ /**
2029
+ * The handles of a node's children, reconciling only the ones that can have changed.
2030
+ *
2031
+ * One changed row in a list of a thousand makes the list re-clone, and the other 999 are asked
2032
+ * for their handle. A clean child whose style still holds under this node's is its committed
2033
+ * handle as it stands, so it is answered here without the call, the resolve and the walk -
2034
+ * which, per child, was most of what a one-row update cost.
2035
+ */
2036
+ reconcileChildren(node, style) {
2037
+ const context = style?.context;
2038
+ if (HOIST_TARGETS.has(node.name))
2039
+ return this.reconcileHoistTarget(node, context);
2040
+ const handles = [];
2041
+ for (const child of node.children) {
2042
+ if (child.kind === 'anchor' || this.committedElsewhere(child) || this.withheld(child)) {
2043
+ continue;
2044
+ }
2045
+ handles.push(this.reconcileUnder(node, child, context));
2046
+ }
2047
+ return handles;
2048
+ }
2049
+ /**
2050
+ * Whether a child is left out of its parent's commit: a modal host whose `visible` is false.
2051
+ *
2052
+ * The native host covers the screen and takes every touch whether or not it presents anything,
2053
+ * which is why React Native's Modal.js renders null while hidden. This is the same thing, so a
2054
+ * hidden `<modal>` leaves the screen under it usable. On iOS a modal that was showing stays
2055
+ * committed, with `visible` false, until native reports `topDismiss`: Modal.js keeps it that
2056
+ * long so the dismissal animates and `(dismiss)` fires. Android drops it at once, as Modal.js
2057
+ * does there.
2058
+ */
2059
+ /** Remember an iOS modal host committed as showing, so hiding it waits for `topDismiss`. */
2060
+ notePresented(node, viewName) {
2061
+ if (viewName !== MODAL_HOST || node.props['visible'] === false)
2062
+ return;
2063
+ if (platformOS === 'ios')
2064
+ node.presented = true;
2065
+ }
2066
+ withheld(child) {
2067
+ if (child.props['visible'] !== false || child.presented)
2068
+ return false;
2069
+ return viewNameOf(child) === MODAL_HOST;
2070
+ }
2071
+ /**
2072
+ * `reconcileChildren` for a node other nodes hoist into: its own children, each followed by
2073
+ * the hoisted nodes written inside it, then those written in it directly, then the views it
2074
+ * keeps for hoisted nodes that are gone.
2075
+ */
2076
+ reconcileHoistTarget(node, context) {
2077
+ const handles = [];
2078
+ const hoisted = this.hoistedInto(node);
2079
+ const landed = new Set();
2080
+ // One written directly in the node goes after its siblings: a page writes its header first,
2081
+ // and UIKit collapses a large title against the scroll view down the screen's first subviews,
2082
+ // which RNS keeps clear by never letting the header config be the first.
2083
+ const direct = [];
2084
+ for (const child of node.children) {
2085
+ if (child.kind === 'anchor' || this.committedElsewhere(child) || this.withheld(child)) {
2086
+ continue;
2087
+ }
2088
+ if (this.hoisted.has(child))
2089
+ direct.push(child);
2090
+ else
2091
+ handles.push(this.reconcileUnder(node, child, context));
2092
+ const moved = hoisted?.get(child);
2093
+ if (moved)
2094
+ for (const written of moved)
2095
+ handles.push(this.land(node, written, landed));
2096
+ }
2097
+ for (const child of direct)
2098
+ handles.push(this.land(node, child, landed));
2099
+ if (node.kept)
2100
+ this.standIn(node, landed, handles);
2101
+ return handles;
2102
+ }
2103
+ /**
2104
+ * A child's handle as a child of `parent`, created again when it was committed under another.
2105
+ *
2106
+ * Native gives a view one parent for as long as it lives: `ShadowNodeFamily::setParent`
2107
+ * asserts it in a debug build, and a release build keeps the first parent and measures and lays
2108
+ * the view out against the wrong ancestry. A node Angular moves - content projected into a
2109
+ * container that was mounted again, a scroll view Android wraps in its swipe layout - is
2110
+ * therefore created afresh, with everything under it, as React creates an element that moves.
2111
+ * Its native state goes with it, as it would there.
2112
+ */
2113
+ reconcileUnder(parent, child, context) {
2114
+ if (child.committed && child.committedUnder !== parent)
2115
+ this.forgetCommitted(child);
2116
+ const handle = this.stillCommitted(child, context) ?? this.reconcile(child);
2117
+ child.committedUnder = parent;
2118
+ return handle;
2119
+ }
2120
+ /** Let a node and everything under it be created again at the next commit. */
2121
+ forgetCommitted(node) {
2122
+ node.committed = null;
2123
+ node.committedUnder = null;
2124
+ node.styleCommitted = null;
2125
+ for (const child of node.children)
2126
+ if (child.committed)
2127
+ this.forgetCommitted(child);
2128
+ }
2129
+ /** A hoisted node, which commits into an ancestor further up than the parent it is in. */
2130
+ committedElsewhere(child) {
2131
+ return this.hoisted.has(child) && this.hoistsAbove(child);
2132
+ }
2133
+ /**
2134
+ * Reconcile a hoisted element committing into `target`, into the native view `target` already
2135
+ * keeps for its name when it has one (`HoistOptions.standIn`), and keep the view it commits as.
2136
+ */
2137
+ land(target, child, landed) {
2138
+ if (!STAND_INS[child.name])
2139
+ return this.reconcileUnder(target, child);
2140
+ const kept = target.kept?.get(child.name);
2141
+ if (kept && kept.node !== child && child.committed === null && !isWithin(kept.node, target)) {
2142
+ // A node taken out without being destroyed would otherwise come back holding this tag too.
2143
+ if (kept.node)
2144
+ kept.node.committed = null;
2145
+ child.committed = kept.committed;
2146
+ child.propsDirty = true;
2147
+ }
2148
+ const handle = this.reconcile(child);
2149
+ child.committedUnder = target;
2150
+ (target.kept ??= new Map()).set(child.name, { node: child, committed: child.committed });
2151
+ landed.add(child.name);
2152
+ return handle;
2153
+ }
2154
+ /**
2155
+ * Commit the stand-in props onto every view `target` keeps whose element is gone, on the view
2156
+ * itself so the native side sees an update rather than a removal.
2157
+ */
2158
+ standIn(target, landed, handles) {
2159
+ for (const [name, kept] of target.kept) {
2160
+ if (landed.has(name))
2161
+ continue;
2162
+ if (kept.node) {
2163
+ const props = {
2164
+ ...DEFAULT_PROPS[registeredViewName(name) ?? DEFAULT_VIEW],
2165
+ ...STAND_INS[name],
2166
+ };
2167
+ const payload = diffProps(kept.committed.props, props);
2168
+ const handle = this.clone(kept.committed.handle, payload && this.processed(payload), []);
2169
+ kept.committed = { handle, tag: kept.committed.tag, props, childHandles: [] };
2170
+ kept.node = null;
2171
+ }
2172
+ handles.push(kept.committed.handle);
2173
+ }
2174
+ }
2175
+ /** A clean child's committed handle, when its style still holds under its parent's. */
2176
+ stillCommitted(child, context) {
2177
+ const committed = child.committed;
2178
+ if (committed === null ||
2179
+ context === undefined ||
2180
+ child.propsDirty ||
2181
+ child.structureDirty ||
2182
+ child.subtreeDirty) {
2183
+ return null;
2184
+ }
2185
+ if (child.kind === 'text')
2186
+ return committed.handle;
2187
+ const holds = child.styleCache === child.styleCommitted && this.styles.holds(child, context);
2188
+ return holds ? committed.handle : null;
2189
+ }
2190
+ /**
2191
+ * The hoisted nodes that commit as direct children of `node`, keyed by the child of `node` each
2192
+ * was written inside. Null when there are none, which is nearly always.
2193
+ */
2194
+ hoistedInto(node) {
2195
+ let found = null;
2196
+ for (const candidate of this.hoisted) {
2197
+ if (candidate.parent === node || HOISTS[candidate.name] !== node.name)
2198
+ continue;
2199
+ let written = candidate;
2200
+ let above = candidate.parent;
2201
+ while (above && above !== node && above.name !== node.name) {
2202
+ written = above;
2203
+ above = above.parent;
2204
+ }
2205
+ if (above !== node)
2206
+ continue;
2207
+ found ??= new Map();
2208
+ const list = found.get(written);
2209
+ if (list)
2210
+ list.push(candidate);
2211
+ else
2212
+ found.set(written, [candidate]);
2213
+ }
2214
+ return found;
2215
+ }
2216
+ /** Whether a hoisted node commits further up than the parent it was written in. */
2217
+ hoistsAbove(node) {
2218
+ const target = HOISTS[node.name];
2219
+ if (node.parent?.name === target)
2220
+ return false;
2221
+ for (let above = node.parent?.parent; above; above = above.parent) {
2222
+ if (above.name === target)
2223
+ return true;
2224
+ }
2225
+ return false;
2226
+ }
2227
+ create(node, viewName, props, childHandles) {
2228
+ if (this.dev) {
2229
+ this.checkClaimed(node);
2230
+ this.checkKnown(node);
2231
+ }
2232
+ const tag = nextTag();
2233
+ this.stats.createdNodes++;
2234
+ // instanceHandle is the retained node itself: it is what Fabric hands back on events.
2235
+ const handle = this.fabric.createNode(tag, viewName, this.rootTag, this.processed(props), node);
2236
+ for (const child of childHandles)
2237
+ this.fabric.appendChild(handle, child);
2238
+ node.committed = { handle, tag, props, childHandles };
2239
+ this.clearFlags(node);
2240
+ return handle;
2241
+ }
2242
+ /**
2243
+ * Angular's own unknown-element check does not run in this pipeline, so a `<text>` in a
2244
+ * template that never imported `Text` compiles, renders as a plain view, and looks like a
2245
+ * layout bug. On the first commit of any known element with no component behind it, say so.
2246
+ */
2247
+ checkClaimed(node) {
2248
+ if (node.kind !== 'element' || node.claimed || !PRIMITIVE_NAMES.has(node.name))
2249
+ return;
2250
+ if (this.reported.has(node.name))
2251
+ return;
2252
+ this.reported.add(node.name);
2253
+ console.error(`[angular-native] <${node.name}> is used in a template that does not import ` +
2254
+ `${className(node.name)}. Add it to the component's \`imports\` from ` +
2255
+ `'@ng-native/components'; without it the element renders as a plain view.`);
2256
+ }
2257
+ /**
2258
+ * The misspelt-prop check. Angular's own "Can't bind to" check needs a DOM to run, so a
2259
+ * `[numberofLines]` on a `<text>`, or a static `iosBackgroundColor` on a `<switch>`, went to
2260
+ * native as a prop no view reads, and nothing said so. A prop on a host primitive that its
2261
+ * component did not declare is reported once per element name and prop, with the declared name
2262
+ * it most likely meant when there is one.
2263
+ */
2264
+ undefinedTokens = new Set();
2265
+ /** Said once per name: a phone has no inspector to show the declaration struck out. */
2266
+ reportUndefinedToken(name, props) {
2267
+ if (this.undefinedTokens.has(name))
2268
+ return;
2269
+ this.undefinedTokens.add(name);
2270
+ console.warn(`[angular-native] var(${name}) in ${props.join(', ')} names a custom property nothing in` +
2271
+ ` scope defines, so the declaration is dropped, as a browser drops it. Define ${name} on` +
2272
+ ` :root or an ancestor, or give the var() a fallback.`);
2273
+ }
2274
+ checkProps(node) {
2275
+ if (!node.claimed || !PRIMITIVE_NAMES.has(node.name))
2276
+ return;
2277
+ const declared = DECLARED_PROPS.get(node.name);
2278
+ if (!declared)
2279
+ return;
2280
+ for (const key of Object.keys(node.props)) {
2281
+ if (this.isDeclared(declared, key, node.props[key]))
2282
+ continue;
2283
+ const report = `${node.name} ${key}`;
2284
+ if (this.reported.has(report))
2285
+ continue;
2286
+ this.reported.add(report);
2287
+ const meant = [...declared].find((name) => name.toLowerCase() === key.toLowerCase());
2288
+ console.warn(`[angular-native] <${node.name}> has no prop '${key}', so native ignores it.` +
2289
+ (meant ? ` Did you mean '${meant}'?` : '') +
2290
+ ` Check the spelling against the ${className(node.name)} page, or declare it with` +
2291
+ ` declareNativeProps('${node.name}', ['${key}']) if the native view does read it.`);
2292
+ }
2293
+ }
2294
+ isDeclared(declared, key, value) {
2295
+ return (declared.has(key) ||
2296
+ ENGINE_PROPS.has(key) ||
2297
+ this.declaredAttributes.has(key) ||
2298
+ // A forms directive's input has a report of its own, naming the import it is missing.
2299
+ isFormsInput(key) ||
2300
+ key.includes('-') ||
2301
+ // A bare marker attribute, `<text listHeader>`, is there for a selector to match.
2302
+ value === '');
2303
+ }
2304
+ /**
2305
+ * Element names some template's directives select by tag: every component with an element
2306
+ * selector, and directives such as Angular's `router-outlet` that own an element without
2307
+ * hosting a component. Declared by the platform adapter, which sees each template's directive
2308
+ * registry as the component is created.
2309
+ */
2310
+ declaredElements = new Set();
2311
+ /** An element name a directive or component selects, and so is not a typo. */
2312
+ declareElement(name) {
2313
+ this.declaredElements.add(name);
2314
+ }
2315
+ /**
2316
+ * Attribute names that belong to Angular rather than to a native view: what a directive selects
2317
+ * on or takes as an input, and what a component's `<ng-content select>` projects by. Angular
2318
+ * writes a static attribute to the node whatever consumes it, so without these a
2319
+ * `nativeRouterLink` or a `listHeader` marker would read as a misspelt prop. See `checkProps`.
2320
+ */
2321
+ declaredAttributes = new Set();
2322
+ declareAttribute(name) {
2323
+ this.declaredAttributes.add(name);
2324
+ }
2325
+ /**
2326
+ * The typo check. The set a name is looked up in is app-wide rather than per template, so a
2327
+ * directive imported in one template also quiets its name in another that forgot it. That is
2328
+ * the trade for not holding a registry per component, and the typo is the case that matters.
2329
+ */
2330
+ checkKnown(node) {
2331
+ if (node.kind !== 'element' || node.componentHost || node.name in VIEW_NAMES)
2332
+ return;
2333
+ if (this.declaredElements.has(node.name) || this.reported.has(node.name))
2334
+ return;
2335
+ this.reported.add(node.name);
2336
+ console.error(`[angular-native] <${node.name}> is not a known element: no component, directive or ` +
2337
+ `registered native view claims it, so it renders as an empty view. Check the spelling, ` +
2338
+ `or import the component that owns it.`);
2339
+ }
2340
+ // --- responder system -------------------------------------------------
2341
+ responders = new WeakMap();
2342
+ currentResponder = null;
2343
+ /** Register a node as a candidate responder. Returns an unsubscribe. */
2344
+ setResponder(node, handlers) {
2345
+ this.registerEventHandler();
2346
+ this.responders.set(node, handlers);
2347
+ this.keepNative(node, true);
2348
+ return () => {
2349
+ this.responders.delete(node);
2350
+ this.keepNative(node, false);
2351
+ if (this.currentResponder !== node)
2352
+ return;
2353
+ // Torn down under the finger: no release is coming, so the chain it marked is cleared
2354
+ // here or it stays `:active` for good.
2355
+ const changed = this.setActiveChain(node, false);
2356
+ this.tellNative(node, false);
2357
+ this.currentResponder = null;
2358
+ if (changed)
2359
+ this.commit();
2360
+ };
2361
+ }
2362
+ /**
2363
+ * Keep a native view for this node, or stop keeping one.
2364
+ *
2365
+ * Fabric flattens a view whose props say nothing visual and nothing interactive: no native view
2366
+ * is created and its layout is folded into the parent. Responders live here, in JavaScript, and
2367
+ * Fabric cannot see them - so a transparent touch-catcher looks exactly like a view worth
2368
+ * flattening, and gets flattened, and never receives the touch it exists to catch.
2369
+ *
2370
+ * That is a popover that will not close. The full-screen catcher over the app has no background
2371
+ * and nothing else on it; the dialog's backdrop survived the same treatment only because
2372
+ * `bg-black/50` happened to make it visual.
2373
+ *
2374
+ * Only plain views: flattening is `ViewShadowNode`'s, so a paragraph, a scroll view or a text
2375
+ * input is already a native view and marking it would be a prop on every text node in the tree
2376
+ * that buys nothing.
2377
+ *
2378
+ * A floor rather than an override: `collapsable="true"` written on an element is left alone,
2379
+ * because someone who writes it means it.
2380
+ */
2381
+ keepNative(node, keep) {
2382
+ if (viewNameOf(node) !== DEFAULT_VIEW)
2383
+ return;
2384
+ if (keep) {
2385
+ if (node.props['collapsable'] === undefined)
2386
+ this.setProp(node, 'collapsable', false);
2387
+ return;
2388
+ }
2389
+ if (node.props['collapsable'] === false)
2390
+ this.setProp(node, 'collapsable', null);
2391
+ }
2392
+ /**
2393
+ * A view command, by name: `focus` and `blur` on a text input, `scrollTo` on a scroll view.
2394
+ * Fabric addresses the committed shadow node, so a node that has not reached a commit yet has
2395
+ * nothing to receive it and the call is dropped.
2396
+ */
2397
+ dispatchCommand(node, name, args = []) {
2398
+ const handle = node.committed?.handle;
2399
+ if (handle && this.fabric.dispatchCommand)
2400
+ this.fabric.dispatchCommand(handle, name, args);
2401
+ }
2402
+ /**
2403
+ * Where a node is, in window coordinates.
2404
+ *
2405
+ * `(layout)` reports a frame in the parent's coordinates, which sizes a view and says nothing
2406
+ * about where it is relative to anything else. This is what an anchored overlay needs: a
2407
+ * popover and its trigger live in different parts of the tree and can only be compared in the
2408
+ * window's frame.
2409
+ *
2410
+ * The callback is the platform's own shape rather than a promise, and on the new architecture
2411
+ * it fires before this returns - so a caller that needs the value immediately can capture it,
2412
+ * and one that does not is unaffected if that ever changes.
2413
+ *
2414
+ * Silent when the node has never been committed, or when the host has no `measureInWindow`.
2415
+ * Reporting zeroes would be worse than reporting nothing: a zero frame is a position, and an
2416
+ * overlay would believe it and place itself in the corner.
2417
+ */
2418
+ measure(node, into) {
2419
+ const handle = node.committed?.handle;
2420
+ if (!handle || !this.fabric.measureInWindow)
2421
+ return;
2422
+ this.fabric.measureInWindow(handle, (x, y, width, height) => into({ x, y, width, height }));
2423
+ }
2424
+ /**
2425
+ * The react tag a node was committed with, or null if it has never reached a commit.
2426
+ *
2427
+ * This is what the native animation driver writes to: `connectAnimatedNodeToView` takes a tag,
2428
+ * and `findNodeHandle` hands a number straight back, so an animated graph can point at one of
2429
+ * our nodes without a React instance existing anywhere.
2430
+ */
2431
+ tagOf(node) {
2432
+ return node.committed?.tag ?? null;
2433
+ }
2434
+ /**
2435
+ * The shadow node Fabric currently holds for a node, or null before its first commit.
2436
+ *
2437
+ * A clone-on-write tree hands back a different object every time a node's props change, so
2438
+ * this is not an identity. What is stable is the family behind it, which is what native keys a
2439
+ * node by, and is why a UI-thread animation given one of these keeps writing to the right view
2440
+ * however many times the node is cloned afterwards.
2441
+ */
2442
+ shadowNodeOf(node) {
2443
+ return node.committed?.handle ?? null;
2444
+ }
2445
+ /** The node currently owning the gesture, if any. */
2446
+ get responder() {
2447
+ return this.currentResponder;
2448
+ }
2449
+ /**
2450
+ * The node native last reported focused, if any: the text input the keyboard belongs to.
2451
+ *
2452
+ * Checked against the tree rather than trusted, because a focused node that outlives its view
2453
+ * is not a stale style - it is every tap in the application being swallowed. Every scroll view
2454
+ * reads this on every touch to decide whether the touch means "dismiss the keyboard", and one
2455
+ * that decides yes *captures* the touch before any child sees it. So a field left focused after
2456
+ * its view is gone makes the whole app untappable until it is restarted.
2457
+ *
2458
+ * Neither teardown hook can be relied on to clear it. `removeChild` unlinks the subtree's root
2459
+ * and leaves everything under it pointing at its own parent, so a focused descendant still looks
2460
+ * attached; and Angular calls `destroyNode` for the nodes it tracks, not for every node in a
2461
+ * removed subtree. An overlay closing with a field inside it misses both.
2462
+ *
2463
+ * So it is answered by looking: a node still in the tree reaches the root by walking up. That
2464
+ * costs the depth of one node, on a touch, and only while something is focused.
2465
+ */
2466
+ get focused() {
2467
+ if (this.focusedNode && !this.isAttached(this.focusedNode))
2468
+ this.focusedNode = null;
2469
+ return this.focusedNode;
2470
+ }
2471
+ /** Whether a node is still part of the tree, rather than in a subtree that was removed. */
2472
+ isAttached(node) {
2473
+ let current = node;
2474
+ while (current) {
2475
+ if (current === this.root)
2476
+ return true;
2477
+ current = current.parent;
2478
+ }
2479
+ return false;
2480
+ }
2481
+ /** The host's `resolveAssetSource`, for a component that needs an asset's size up front. */
2482
+ resolveAsset(value) {
2483
+ return this.resolveAssetSource(value);
2484
+ }
2485
+ /**
2486
+ * The host's `processColor`, and the one place a platform colour becomes one.
2487
+ *
2488
+ * Props are processed by name - anything ending in `color` - which covers every colour that is
2489
+ * a prop. A colour *nested* inside a prop native parses itself is invisible to that rule: a tab
2490
+ * bar's appearance carries eight of them, and unprocessed they arrive as strings native reads
2491
+ * as nothing at all. So this is public, and the commit walk uses it too.
2492
+ *
2493
+ * `platform-color(label)` cannot be resolved at build time - the same declaration is
2494
+ * `{semantic}` on iOS and `{resource_paths}` on Android - so the names travel and the shape is
2495
+ * put on here. `processColor` understands both, exactly as it does for RN's own `PlatformColor`.
2496
+ */
2497
+ color(value) {
2498
+ if (value === undefined)
2499
+ return undefined;
2500
+ const names = value?.platformColor;
2501
+ if (!names)
2502
+ return this.processColor(value);
2503
+ const key = nativePlatform() === 'android' ? 'resource_paths' : 'semantic';
2504
+ return this.processColor({ [key]: [...names] });
2505
+ }
2506
+ /** Root-first, so a capture pass reads forwards and a bubble pass reads backwards. */
2507
+ pathTo(node) {
2508
+ const path = [];
2509
+ for (let current = node; current; current = current.parent)
2510
+ path.push(current);
2511
+ return path.reverse();
2512
+ }
2513
+ negotiate(target, event, phase) {
2514
+ const path = this.pathTo(target);
2515
+ const capture = `on${phase}ShouldSetResponderCapture`;
2516
+ const bubble = `on${phase}ShouldSetResponder`;
2517
+ // Capture runs root -> target, so an ancestor can claim before its children see it.
2518
+ for (const node of path) {
2519
+ if (this.responders.get(node)?.[capture]?.(event, target))
2520
+ return node;
2521
+ }
2522
+ // Bubble runs target -> root: the innermost interested node wins.
2523
+ for (let i = path.length - 1; i >= 0; i--) {
2524
+ if (this.responders.get(path[i])?.[bubble]?.(event, target))
2525
+ return path[i];
2526
+ }
2527
+ return null;
2528
+ }
2529
+ grant(node, event) {
2530
+ const previous = this.currentResponder;
2531
+ if (previous === node)
2532
+ return;
2533
+ if (previous) {
2534
+ const handlers = this.responders.get(previous);
2535
+ // An undefined handler means "yes": RN's default is to allow the takeover.
2536
+ if (handlers?.onResponderTerminationRequest?.(event) === false)
2537
+ return;
2538
+ this.tellNative(previous, false);
2539
+ handlers?.onResponderTerminate?.(event);
2540
+ }
2541
+ // After the takeover is agreed, not before: a refusal would otherwise leave `:active` on a
2542
+ // node that never became the responder. Both calls run, so neither is short-circuited away.
2543
+ const cleared = this.setActiveChain(previous, false);
2544
+ const marked = this.setActiveChain(node, true);
2545
+ this.currentResponder = node;
2546
+ this.tellNative(node, true);
2547
+ this.responders.get(node)?.onResponderGrant?.(event);
2548
+ if (cleared || marked)
2549
+ this.commit();
2550
+ }
2551
+ release(event, terminated) {
2552
+ const node = this.currentResponder;
2553
+ if (!node)
2554
+ return;
2555
+ const changed = this.setActiveChain(node, false);
2556
+ this.currentResponder = null;
2557
+ this.tellNative(node, false);
2558
+ const handlers = this.responders.get(node);
2559
+ if (terminated)
2560
+ handlers?.onResponderTerminate?.(event);
2561
+ else
2562
+ handlers?.onResponderRelease?.(event);
2563
+ if (changed)
2564
+ this.commit();
2565
+ }
2566
+ tellNative(node, isResponder) {
2567
+ const handle = node.committed?.handle;
2568
+ if (!handle || !this.fabric.setIsJSResponder)
2569
+ return;
2570
+ const block = this.responders.get(node)?.blockNativeResponder ?? false;
2571
+ this.fabric.setIsJSResponder(handle, isResponder, block);
2572
+ }
2573
+ /** Runs before listener propagation, as React's responder plugin does. */
2574
+ runResponder(target, type, event) {
2575
+ if (type === TOUCH_START) {
2576
+ const elected = this.negotiate(target, event, 'Start');
2577
+ if (elected)
2578
+ this.grant(elected, event);
2579
+ return;
2580
+ }
2581
+ if (type === TOUCH_MOVE) {
2582
+ if (this.currentResponder) {
2583
+ this.responders.get(this.currentResponder)?.onResponderMove?.(event);
2584
+ }
2585
+ else {
2586
+ const elected = this.negotiate(target, event, 'Move');
2587
+ if (elected)
2588
+ this.grant(elected, event);
2589
+ }
2590
+ return;
2591
+ }
2592
+ if (type === TOUCH_END)
2593
+ this.release(event, false);
2594
+ else if (type === TOUCH_CANCEL)
2595
+ this.release(event, true);
2596
+ }
2597
+ // --- events -----------------------------------------------------------
2598
+ registerEventHandler() {
2599
+ if (this.eventsRegistered || !this.fabric.registerEventHandler)
2600
+ return;
2601
+ this.eventsRegistered = true;
2602
+ this.fabric.registerEventHandler((instanceHandle, topLevelType, nativeEvent) => this.dispatchEvent(instanceHandle, topLevelType, nativeEvent));
2603
+ }
2604
+ /**
2605
+ * Fabric's `(instanceHandle, topLevelType, nativeEvent)` callback.
2606
+ *
2607
+ * **Events propagate up the retained tree.** Fabric hands JS only the hit target; React
2608
+ * implements two-phase propagation itself by walking the fiber tree, and so must we. Without
2609
+ * it a tap on a `<text>` label inside a `<pressable>` reaches the text node and stops, so the
2610
+ * button only responds on its padding. That reads as "taps sometimes do not register".
2611
+ *
2612
+ * ponytail: event priority (`unstable_getCurrentEventPriority` and the
2613
+ * `unstable_*EventPriority` constants) is read into the facade but not acted on. Angular's
2614
+ * zoneless scheduler already flushes on a microtask, which is soon enough for taps. It stops
2615
+ * being soon enough for a controlled TextInput, where the echo has to beat the next
2616
+ * keystroke. `mostRecentEventCount` covers that today by letting native reject a stale write;
2617
+ * a synchronous flush for discrete priority is the fix if it ever stops being enough.
2618
+ *
2619
+ * **Nothing thrown here may escape.** Fabric calls this synchronously from C++
2620
+ * (`UIManagerBinding::dispatchEventToJS`), often from inside a mounting transaction, and a throw
2621
+ * unwinds through that transaction: for a screen push the next commit then reads props freed on
2622
+ * the way out and the process dies with no JS stack at all. Angular
2623
+ * already catches what its own template listeners throw; everything else on this path - the
2624
+ * responder handlers, listeners registered on the engine directly, a commit started by a focus
2625
+ * change - is caught here and handed to `onError`, which the platform points at the app's
2626
+ * `ErrorHandler`. Each listener is guarded on its own, so one that throws does not stop the
2627
+ * bubble for the rest, as in the DOM.
2628
+ */
2629
+ dispatchEvent(instanceHandle, topLevelType, nativeEvent) {
2630
+ const target = instanceHandle;
2631
+ this.trace(target, topLevelType);
2632
+ // Listeners get RN's documented shape, `{nativeEvent}`. Fabric hands us the payload bare;
2633
+ // React wraps it in a synthetic event and every RN API is written against `event.nativeEvent`,
2634
+ // so handlers ported from RN would silently read undefined otherwise.
2635
+ const event = new SyntheticEvent(nativeEvent);
2636
+ try {
2637
+ if (topLevelType === 'topDismiss' && target)
2638
+ this.dismissed(target);
2639
+ if (topLevelType === 'topScroll' && target && this.scrollTimelines.has(target)) {
2640
+ this.measuredScroll(target, nativeEvent);
2641
+ }
2642
+ this.trackFocus(target, topLevelType);
2643
+ if (target)
2644
+ this.cancelPressForScroll(target, topLevelType, event);
2645
+ if (target)
2646
+ this.runResponder(target, topLevelType, event);
2647
+ }
2648
+ catch (error) {
2649
+ this.reportEventError(error, topLevelType);
2650
+ }
2651
+ this.propagate(target, topLevelType, event);
2652
+ }
2653
+ /** A presented modal host has finished leaving, so a hidden one can come out of the tree. */
2654
+ dismissed(target) {
2655
+ if (!target.presented)
2656
+ return;
2657
+ target.presented = undefined;
2658
+ if (target.props['visible'] === false)
2659
+ this.markProps(target, false);
2660
+ }
2661
+ reportEventError(error, topLevelType) {
2662
+ if (this.onError) {
2663
+ this.onError(error, topLevelType);
2664
+ return;
2665
+ }
2666
+ console.error(`[angular-native] an error was thrown while dispatching ${topLevelType}`, error);
2667
+ }
2668
+ /**
2669
+ * A list that scrolls under a finger takes the touch away from whatever it started on.
2670
+ *
2671
+ * Distance alone cannot tell a scroll from a tap. The press rect is the control's own bounds
2672
+ * plus a retention offset, which is right - a 15pt threshold cancels an ordinary thumb tap on a
2673
+ * full-width button - but on a 72pt list row it means a 72pt drag still counts as a tap, and a
2674
+ * 72pt drag is unmistakably a scroll. So the list is asked instead of the arithmetic: it knows
2675
+ * it is scrolling, and every platform cancels the touch at that moment.
2676
+ *
2677
+ * Only a responder *inside* the thing that scrolled, so a scroll view moving does not cancel a
2678
+ * press somewhere else on screen. And only while a touch is in progress: momentum after the
2679
+ * finger has lifted, and programmatic scrolling, have no press to cancel.
2680
+ */
2681
+ cancelPressForScroll(target, type, event) {
2682
+ if (!SCROLL_CANCELS_PRESS.has(type))
2683
+ return;
2684
+ const responder = this.currentResponder;
2685
+ if (!responder || responder === target)
2686
+ return;
2687
+ for (let node = responder; node; node = node.parent) {
2688
+ if (node === target) {
2689
+ this.release(event, true);
2690
+ return;
2691
+ }
2692
+ }
2693
+ }
2694
+ /**
2695
+ * Tell "the native side never sent it" from "it arrived and nothing was listening", which are
2696
+ * otherwise identical symptoms. One line per event, so it is off unless asked for.
2697
+ */
2698
+ trace(target, topLevelType) {
2699
+ if (!globalThis.__angularNativeEventLog)
2700
+ return;
2701
+ let matched = 0;
2702
+ for (let n = target; n; n = DIRECT_EVENTS.has(topLevelType) ? null : n.parent) {
2703
+ matched += n.listeners?.get(topLevelType)?.size ?? 0;
2704
+ }
2705
+ console.log('[event]', topLevelType, target?.name ?? '(no handle)', matched);
2706
+ }
2707
+ /**
2708
+ * `:focus` follows the native events rather than being inferred from a tap, so it is right for a
2709
+ * field focused programmatically or by the keyboard's next-field button.
2710
+ */
2711
+ trackFocus(target, topLevelType) {
2712
+ if (topLevelType === 'topFocus')
2713
+ this.setFocused(target);
2714
+ else if (topLevelType === 'topBlur' && this.focusedNode === target)
2715
+ this.setFocused(null);
2716
+ }
2717
+ /** Walk target to root. Fabric hands JS only the hit target; the propagation is ours to do. */
2718
+ propagate(target, topLevelType, event) {
2719
+ for (let node = target; node; node = node.parent) {
2720
+ const listeners = node.listeners?.get(topLevelType);
2721
+ if (listeners) {
2722
+ for (const fn of [...listeners]) {
2723
+ try {
2724
+ fn(event);
2725
+ }
2726
+ catch (error) {
2727
+ this.reportEventError(error, topLevelType);
2728
+ }
2729
+ }
2730
+ }
2731
+ if (DIRECT_EVENTS.has(topLevelType) || event.isPropagationStopped())
2732
+ return;
2733
+ }
2734
+ }
2735
+ /**
2736
+ * Clone a committed node with whatever changed. Fabric's persistent tree has no in-place edit:
2737
+ * a new revision is a new node, which is why a changed child forces its parent to clone too.
2738
+ *
2739
+ * `children` is null when the child list is unchanged, in which case Fabric keeps the previous
2740
+ * revision's children and there is nothing to append.
2741
+ *
2742
+ * Otherwise this clones the way React does: the no-children form, then `appendChild` per child.
2743
+ * Omitting the child list gives the clone *empty* children, not the old ones, and whether a
2744
+ * binding takes that argument cannot be feature-detected - `UIManagerBinding.cpp` registers
2745
+ * both clone methods with a fixed `paramCount` and argument validation is commented out - so
2746
+ * `fn.length` says nothing. One code path, no detection.
2747
+ */
2748
+ clone(previous, props, children) {
2749
+ this.stats.clonedNodes++;
2750
+ if (children === null)
2751
+ return this.fabric.cloneNodeWithNewProps(previous, props);
2752
+ const handle = props
2753
+ ? this.fabric.cloneNodeWithNewChildrenAndProps(previous, props)
2754
+ : this.fabric.cloneNodeWithNewChildren(previous);
2755
+ for (const child of children)
2756
+ this.fabric.appendChild(handle, child);
2757
+ return handle;
2758
+ }
2759
+ detach(child) {
2760
+ if (child.parent)
2761
+ this.removeChild(child.parent, child);
2762
+ }
2763
+ }
2764
+ //# sourceMappingURL=engine.js.map