@ng-native/fabric 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/engine.js CHANGED
@@ -7,7 +7,9 @@
7
7
  * because a persistent parent holds references to specific child handles. That bubble is
8
8
  * inherent and is what React's own Fabric renderer does.
9
9
  */
10
- import { StyleResolver, } from "./css.js";
10
+ import { boundDeclaration, inlineInherited, setsInherited, StyleResolver, } from "./css.js";
11
+ import { applyAria } from "./aria-props.js";
12
+ import { STYLED_ELEMENTS } from "./element-styles.js";
11
13
  import { animationEvent, bezier, interpolate, readsInheritedColour, sample, step, tracksOf, } from "./transition.js";
12
14
  import { isFormsInput } from "./forms-inputs.js";
13
15
  import { transformList } from "./inline-transform.js";
@@ -240,6 +242,34 @@ const VIEW_NAMES = {
240
242
  * `@ng-native/expo`, and warning about it would be noise.
241
243
  */
242
244
  const PRIMITIVE_NAMES = new Set(Object.keys(VIEW_NAMES));
245
+ /**
246
+ * HTML's text elements, which a template can write as it would for a browser.
247
+ *
248
+ * Each is text when it holds only text and other text elements, and a view when it holds anything
249
+ * else: a box inside a paragraph has no layout, so `<span>Inbox<view class="dot" /></span>` is a
250
+ * view with its text in a paragraph of its own. Decided as the element is committed, by
251
+ * `viewNameOf`. They have no component to import, so they are not `PRIMITIVE_NAMES`.
252
+ */
253
+ const TEXT_ELEMENTS = new Set([
254
+ ...['span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'label', 'strong', 'b', 'em', 'i'],
255
+ ...['u', 's', 'small', 'code', 'mark', 'abbr', 'cite', 'time'],
256
+ ]);
257
+ /** HTML's layout elements, each a plain view, as a browser's are boxes with a name. */
258
+ const LAYOUT_ELEMENTS = [
259
+ ...['div', 'section', 'article', 'header', 'footer', 'main', 'nav', 'ul', 'ol', 'li'],
260
+ ];
261
+ for (const name of TEXT_ELEMENTS)
262
+ VIEW_NAMES[name] = 'Paragraph';
263
+ for (const name of LAYOUT_ELEMENTS)
264
+ VIEW_NAMES[name] = 'View';
265
+ /**
266
+ * What an HTML element has for its name alone, given to the node as it is made: a heading's role,
267
+ * which a binding can replace as it can any prop, and whether the engine has styles for it.
268
+ */
269
+ const HTML_ELEMENTS = new Map([
270
+ ...['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].map((name) => [name, { role: 'header' }]),
271
+ ...[...STYLED_ELEMENTS].map((name) => [name, { styled: true }]),
272
+ ]);
243
273
  /**
244
274
  * The props each host primitive's native view is sent, by element name, as the components
245
275
  * declare them: every input the element's component has, and the props it writes that no input
@@ -274,6 +304,14 @@ export function claimHost(node) {
274
304
  export function markComponentHost(node) {
275
305
  node.componentHost = true;
276
306
  }
307
+ /**
308
+ * Say that a component's host is the native view its element name is registered as: a typed
309
+ * wrapper for the view, where a component of the app's own with the same selector is a plain view.
310
+ * Called from the component's constructor. See `ViewNameOptions.yieldsToComponents`.
311
+ */
312
+ export function keepNativeView(node) {
313
+ node.nativeView = true;
314
+ }
277
315
  /** `text-input` -> `TextInput`: how the components package names the class for an element. */
278
316
  const className = (element) => element.replace(/(^|-)([a-z])/g, (_, __, c) => c.toUpperCase());
279
317
  /** Names Android registers differently. */
@@ -478,6 +516,12 @@ const MODAL_HOST = 'ModalHostView';
478
516
  */
479
517
  export function registerViewName(elementName, viewName, defaultProps, options) {
480
518
  VIEW_NAMES[elementName] = viewName;
519
+ if (options?.yieldsToComponents)
520
+ YIELDING.add(elementName);
521
+ else
522
+ YIELDING.delete(elementName);
523
+ // A name the app registers is the app's: no longer decided by what the element holds.
524
+ TEXT_ELEMENTS.delete(elementName);
481
525
  for (const name of typeof viewName === 'string' ? [viewName] : [viewName.ios, viewName.android]) {
482
526
  if (defaultProps)
483
527
  DEFAULT_PROPS[name] = defaultProps;
@@ -485,6 +529,8 @@ export function registerViewName(elementName, viewName, defaultProps, options) {
485
529
  TEXT_CONTENT_PROPS[name] = options.textContent;
486
530
  }
487
531
  }
532
+ /** The registered names that give way to a component's own host. See `ViewNameOptions`. */
533
+ const YIELDING = new Set();
488
534
  /** The prop each view that takes its text content as a prop reads it from. See `ViewNameOptions`. */
489
535
  const TEXT_CONTENT_PROPS = {};
490
536
  /** Whether `node` takes its text content as a prop rather than as text children. */
@@ -500,6 +546,14 @@ function withTextContent(node, viewName, props) {
500
546
  if (text)
501
547
  props[prop] = text;
502
548
  }
549
+ /**
550
+ * The whitespace a paragraph drops at its ends is what CSS collapses: spaces, tabs and line
551
+ * breaks. A no-break space is text, as in a browser, so `&nbsp;` is how a value keeps a space at
552
+ * its end; `String.prototype.trim` would take that too.
553
+ */
554
+ const trimStart = (text) => text.replace(/^[ \t\n\r\f]+/, '');
555
+ const trimEnd = (text) => text.replace(/[ \t\n\r\f]+$/, '');
556
+ const trimEnds = (text) => trimEnd(trimStart(text));
503
557
  /**
504
558
  * The text written inside a view that takes it as a prop: every run joined, with the whitespace at
505
559
  * the two ends dropped as a paragraph drops it, and kept inside. A nested view is drawn after the
@@ -514,7 +568,7 @@ function textContent(node) {
514
568
  else if (child.kind === 'element')
515
569
  nested = true;
516
570
  }
517
- return nested ? text.trimStart() : text.trim();
571
+ return nested ? trimStart(text) : trimEnd(trimStart(text));
518
572
  }
519
573
  /** An element's registered view name, for the platform the host reported. */
520
574
  function registeredViewName(elementName) {
@@ -528,6 +582,17 @@ function registeredViewName(elementName) {
528
582
  * a paragraph, and a text input on either platform. See `Engine.remeasureText`.
529
583
  */
530
584
  const MEASURED_VIEWS = new Set(['Paragraph', 'TextInput', 'AndroidTextInput']);
585
+ /** Leave a family that is still loading out of `style`, remembering it. See `loadingFamilies`. */
586
+ function holdLoadingFamily(node, style) {
587
+ const family = style['fontFamily'];
588
+ if (typeof family === 'string' && loadingFamilies.has(family)) {
589
+ delete style['fontFamily'];
590
+ node.heldFamily = family;
591
+ }
592
+ else if (node.heldFamily !== undefined) {
593
+ node.heldFamily = undefined;
594
+ }
595
+ }
531
596
  /** What each mounted app does when faces register. See `fontsRegistered`. */
532
597
  const fontListeners = new Set();
533
598
  /** Hear about faces registering with the platform, until the returned function is called. */
@@ -547,6 +612,45 @@ export function fontsRegistered(families) {
547
612
  for (const listener of [...fontListeners])
548
613
  listener(names);
549
614
  }
615
+ /**
616
+ * Families a load has asked the platform for and not yet settled, by how many loads asked. Text
617
+ * naming one is laid out in the fallback without the name until it settles: native caches a
618
+ * text's measurement by its family name, so a fallback measurement under the face's name would
619
+ * outlast the face arriving.
620
+ */
621
+ const loadingFamilies = new Map();
622
+ /** Faces a load is about to ask the platform for. See `loadingFamilies`. */
623
+ export function fontsLoading(families) {
624
+ for (const family of families)
625
+ loadingFamilies.set(family, (loadingFamilies.get(family) ?? 0) + 1);
626
+ }
627
+ /** What each mounted app does when a load settles. See `fontsSettled`. */
628
+ const settleListeners = new Set();
629
+ /** Hear about loads settling, until the returned function is called. */
630
+ export function onFontsSettled(listener) {
631
+ settleListeners.add(listener);
632
+ return () => settleListeners.delete(listener);
633
+ }
634
+ /**
635
+ * Faces a load asked for and has finished with, registered or not, before `fontsRegistered`.
636
+ * Text held back for one that no load still asks for is laid out with the name again: see
637
+ * `Engine.fontsSettled`.
638
+ */
639
+ export function fontsSettled(families) {
640
+ const settled = new Set();
641
+ for (const family of families) {
642
+ const left = (loadingFamilies.get(family) ?? 1) - 1;
643
+ if (left > 0)
644
+ loadingFamilies.set(family, left);
645
+ else {
646
+ loadingFamilies.delete(family);
647
+ settled.add(family);
648
+ }
649
+ }
650
+ if (settled.size)
651
+ for (const listener of [...settleListeners])
652
+ listener(settled);
653
+ }
550
654
  /** Whether a paragraph, or a span in it, was committed asking for one of these families. */
551
655
  function namesFamily(node, families) {
552
656
  const family = node.committed?.props['fontFamily'];
@@ -617,12 +721,13 @@ const STAND_INS = {};
617
721
  */
618
722
  function paragraphText(node) {
619
723
  let root = node.parent;
620
- if (root?.name !== 'text')
621
- return node.text;
724
+ // Text written straight into a view is a paragraph of its own, and all of it.
725
+ if (root === null || !isTextElement(root))
726
+ return node.box ? trimEnds(node.text) : node.text;
622
727
  // The commonest paragraph by far: one run and nothing else, so it is both the first and last.
623
- if (root.children.length === 1 && root.parent?.name !== 'text')
624
- return node.text.trim();
625
- while (root.parent?.name === 'text')
728
+ if (root.children.length === 1 && !isTextElement(root.parent))
729
+ return trimEnds(node.text);
730
+ while (isTextElement(root.parent))
626
731
  root = root.parent;
627
732
  const runs = [];
628
733
  const collect = (from) => {
@@ -631,18 +736,39 @@ function paragraphText(node) {
631
736
  if (child.text)
632
737
  runs.push(child);
633
738
  }
634
- else if (child.name === 'text')
739
+ else if (isTextElement(child))
635
740
  collect(child);
636
741
  }
637
742
  };
638
743
  collect(root);
639
744
  let text = node.text;
640
745
  if (runs[0] === node)
641
- text = text.replace(/^\s+/, '');
746
+ text = trimStart(text);
642
747
  if (runs[runs.length - 1] === node)
643
- text = text.replace(/\s+$/, '');
748
+ text = trimEnd(text);
644
749
  return text;
645
750
  }
751
+ /**
752
+ * Whether an element is text: a `<text>`, or one of HTML's text elements that holds only text and
753
+ * other such elements. Worked out each time, from the tree as it is, so nothing can go stale.
754
+ */
755
+ function isTextElement(node) {
756
+ if (node === null || node.kind !== 'element')
757
+ return false;
758
+ if (node.name === 'text')
759
+ return true;
760
+ // One lookup settles nearly every other element, which is a view.
761
+ if (registeredViewName(node.name) !== PARAGRAPH)
762
+ return false;
763
+ return !TEXT_ELEMENTS.has(node.name) || holdsOnlyText(node);
764
+ }
765
+ function holdsOnlyText(node) {
766
+ for (const child of node.children ?? []) {
767
+ if (child.kind === 'element' && !isTextElement(child))
768
+ return false;
769
+ }
770
+ return true;
771
+ }
646
772
  /**
647
773
  * The native view a node commits as: `Paragraph` for a `<text>`, `VirtualText` for one nested in
648
774
  * another, `RawText` for a run of text, and `View` for anything unregistered, which is what an
@@ -658,9 +784,169 @@ export function viewNameOf(node) {
658
784
  // RN models nested text as a span inside a paragraph: `Paragraph > [RawText, Text > RawText]`.
659
785
  // Committing a nested `<text>` as another Paragraph gives a paragraph inside a paragraph,
660
786
  // which lays out as a separate block instead of flowing inline.
661
- if (node.name === 'text' && node.parent?.name === 'text')
662
- return VIRTUAL_TEXT;
663
- return registeredViewName(node.name) ?? DEFAULT_VIEW;
787
+ // A component of the app's own under a name a view set registered: its host is a plain view.
788
+ if (node.componentHost && !node.nativeView && YIELDING.has(node.name))
789
+ return DEFAULT_VIEW;
790
+ const registered = registeredViewName(node.name);
791
+ // Nearly every element: a view of some kind, by its name alone.
792
+ if (registered !== PARAGRAPH)
793
+ return registered ?? DEFAULT_VIEW;
794
+ // One of HTML's text elements that holds something other than text: a view.
795
+ if (node.name !== 'text' && TEXT_ELEMENTS.has(node.name) && !holdsOnlyText(node)) {
796
+ return DEFAULT_VIEW;
797
+ }
798
+ return isTextElement(node.parent) ? VIRTUAL_TEXT : PARAGRAPH;
799
+ }
800
+ /**
801
+ * The props every native view reads as a boolean: the `bool` fields of React Native's
802
+ * `BaseViewProps`, `AccessibilityProps` and `HostPlatformViewProps`, by the names JavaScript sends.
803
+ *
804
+ * An attribute arrives as text. A component that declares the prop as an input turns it into a
805
+ * boolean itself, but an element nothing claims, such as a component's own host, holds what it
806
+ * was given, and Android refuses a string where it reads a boolean: `focusable="false"` is
807
+ * `java.lang.String cannot be cast to java.lang.Boolean` at the first commit. iOS ignores it.
808
+ *
809
+ * So the text is made a boolean as it is committed, `"false"` false and anything else true, as an
810
+ * attribute reads in HTML. The node keeps the text, which is what `[focusable="false"]` in a
811
+ * stylesheet matches against.
812
+ */
813
+ const BOOLEAN_VIEW_PROPS = new Set([
814
+ 'accessibilityElementsHidden',
815
+ 'accessibilityIgnoresInvertColors',
816
+ 'accessibilityRespondsToUserInteraction',
817
+ 'accessibilityShowsLargeContentViewer',
818
+ 'accessibilityViewIsModal',
819
+ 'accessible',
820
+ 'collapsable',
821
+ 'collapsableChildren',
822
+ 'focusable',
823
+ 'hasTVPreferredFocus',
824
+ 'needsOffscreenAlphaCompositing',
825
+ // The flags native reads to know an event has a listener, which are booleans as well.
826
+ 'onAccessibilityAction',
827
+ 'onAccessibilityEscape',
828
+ 'onAccessibilityMagicTap',
829
+ 'onAccessibilityTap',
830
+ 'onLayout',
831
+ 'removeClippedSubviews',
832
+ 'renderToHardwareTextureAndroid',
833
+ 'screenReaderFocusable',
834
+ 'shouldRasterizeIOS',
835
+ ]);
836
+ /**
837
+ * Whether `node` holds nothing `:empty` sees besides `moved`: so it was empty before `moved` came,
838
+ * or is now that it has gone. An anchor is a comment on the web, which `:empty` does not see.
839
+ */
840
+ function wasOrIsEmptyWithout(node, moved) {
841
+ if (moved.kind === 'anchor')
842
+ return false;
843
+ for (const child of node.children) {
844
+ if (child !== moved && child.kind !== 'anchor')
845
+ return false;
846
+ }
847
+ return true;
848
+ }
849
+ /**
850
+ * A node's own props onto what it commits with. No native prop has a hyphen: `data-*` and `aria-*`
851
+ * attributes stay on the node for selectors to match, and an `aria-*` one is mapped to the prop
852
+ * native reads.
853
+ */
854
+ function writeOwnProps(own, props) {
855
+ let aria = false;
856
+ for (const key of Object.keys(own)) {
857
+ if (key.includes('-'))
858
+ aria ||= key.startsWith('aria-');
859
+ else if (key !== 'style' && key !== INTRINSIC_SIZE && key !== STYLE_OVERRIDE) {
860
+ props[key] = committedProp(key, own[key]);
861
+ }
862
+ }
863
+ if (aria)
864
+ applyAria(own, props);
865
+ }
866
+ /** A prop as it is committed: a boolean view prop held as text is the boolean the text says. */
867
+ function committedProp(key, value) {
868
+ return typeof value === 'string' && BOOLEAN_VIEW_PROPS.has(key) ? value !== 'false' : value;
869
+ }
870
+ /**
871
+ * The `pointer-events` an element ends up with, in the order its props are merged: its inline
872
+ * style's, then its `pointerEvents` prop, then what the sheets resolve. The inline one is read for
873
+ * itself because an element no rule matches resolves to what it inherits, with its own inline
874
+ * value only in what it hands down.
875
+ */
876
+ function pointerEventsOf(node, resolved) {
877
+ const inline = node.inlineInherits ? inlineInherited(node.props['style']) : null;
878
+ const own = inline?.['pointerEvents'];
879
+ // Merged over the prop, so its `inherit` is what the sheets and the parent settled, not the prop.
880
+ if (own === 'inherit')
881
+ return resolved['pointerEvents'];
882
+ return own ?? node.props['pointerEvents'] ?? resolved['pointerEvents'];
883
+ }
884
+ /**
885
+ * A node's `pointer-events` as native takes it, once its props and styles are merged.
886
+ *
887
+ * In CSS `none` is the element alone, and a descendant's `auto` takes touches again, which native
888
+ * calls `box-none`: what a `none` with something inside to open it is committed as. Native's own
889
+ * `none` is the whole subtree. It is what a `none` with nothing inside to open it stays, because
890
+ * Android's text and images take no `pointerEvents` of their own and only an ancestor's `none`
891
+ * keeps touches off them. It is also what the `pointerEvents` prop means, so a `none` that is the
892
+ * prop's is left as it is.
893
+ *
894
+ * An inline `inherit` is what the element has without it, which is `resolved`: what the sheets
895
+ * and its parent settled.
896
+ */
897
+ function nativePointerEvents(node, style, resolved) {
898
+ let value = style['pointerEvents'];
899
+ if (value === undefined)
900
+ return;
901
+ if (value === 'inherit') {
902
+ value = resolved;
903
+ if (value === undefined)
904
+ delete style['pointerEvents'];
905
+ else
906
+ style['pointerEvents'] = value;
907
+ }
908
+ if (value === 'none' && node.touchWithin && node.props['pointerEvents'] !== 'none') {
909
+ style['pointerEvents'] = 'box-none';
910
+ }
911
+ }
912
+ /** Whether `node`, whose `pointer-events` is `value`, takes touches or holds something that does. */
913
+ function takesTouches(node, value, prop) {
914
+ if (value === undefined)
915
+ return false;
916
+ // The prop's `none` is native's: the whole subtree, whatever is inside it.
917
+ if (value === 'none' && prop === 'none')
918
+ return false;
919
+ // CSS's `none` and native's `box-none` take no touches themselves: their children answer.
920
+ if (value === 'none' || value === 'box-none') {
921
+ return node.children.some((child) => child.touchWithin);
922
+ }
923
+ return true;
924
+ }
925
+ /**
926
+ * Records on `node` whether it, or something inside it, takes touches where it would inherit
927
+ * `none`. When that changes a `none` element's own answer, its props are marked to be committed
928
+ * again.
929
+ *
930
+ * Called as a node is reconciled, after its children, so theirs are already current: a child that
931
+ * was skipped as clean has nothing beneath it that changed, and a change beneath marks every
932
+ * ancestor to come back through here.
933
+ */
934
+ function noteTouches(node, style) {
935
+ if (!style)
936
+ return;
937
+ // Nearly every node: no `pointer-events` in any sheet, style or prop, and nothing recorded.
938
+ const resolved = style.style['pointerEvents'];
939
+ const prop = node.props['pointerEvents'];
940
+ if (resolved === undefined && prop === undefined && !node.inlineInherits && !node.touchWithin) {
941
+ return;
942
+ }
943
+ const value = pointerEventsOf(node, style.style);
944
+ const within = takesTouches(node, value, prop);
945
+ if (within === !!node.touchWithin)
946
+ return;
947
+ node.touchWithin = within;
948
+ if (value === 'none')
949
+ node.propsDirty = true;
664
950
  }
665
951
  /** Whether `node` is `ancestor` or somewhere under it. */
666
952
  function isWithin(node, ancestor) {
@@ -763,6 +1049,10 @@ class RetainedNode {
763
1049
  propsDirty = false;
764
1050
  structureDirty = false;
765
1051
  subtreeDirty = false;
1052
+ touchWithin = undefined;
1053
+ box = undefined;
1054
+ anonymous = undefined;
1055
+ styled = undefined;
766
1056
  transitions;
767
1057
  playing;
768
1058
  scrolled;
@@ -896,8 +1186,9 @@ const IS_COLOR_PROP = /color(android)?$/i;
896
1186
  const colorProps = new Map();
897
1187
  function isColorProp(key) {
898
1188
  let answer = colorProps.get(key);
899
- if (answer === undefined)
900
- colorProps.set(key, (answer = IS_COLOR_PROP.test(key)));
1189
+ if (answer === undefined) {
1190
+ colorProps.set(key, (answer = IS_COLOR_PROP.test(key) || BRUSH_PROPS.has(key)));
1191
+ }
901
1192
  return answer;
902
1193
  }
903
1194
  const ASSET_PROPS = new Set(['source', 'defaultSource', 'loadingIndicatorSource']);
@@ -910,7 +1201,30 @@ const ASSET_PROPS = new Set(['source', 'defaultSource', 'loadingIndicatorSource'
910
1201
  * approximated, it is dropped.
911
1202
  */
912
1203
  const NESTED_COLOR_LIST_PROPS = new Set(['boxShadow']);
1204
+ /** The props of an SVG shape that take a brush, not a colour. */
1205
+ const BRUSH_PROPS = new Set(['fill', 'stroke']);
913
1206
  /** Styles arrive as objects, arrays, nested arrays and nulls. Reduce to one object. */
1207
+ /**
1208
+ * A bound transform is still the CSS string: see `inline-transform.ts`. One CSS cannot read
1209
+ * leaves the transform the rules set, as a browser drops it.
1210
+ */
1211
+ function boundTransform(node, style, cascaded) {
1212
+ const text = style['transform'];
1213
+ if (typeof text !== 'string')
1214
+ return style;
1215
+ // The same list for the same string, commit after commit: a transition compares the two ends
1216
+ // it is given, and a list made afresh each time reads as a new place to go.
1217
+ if (node.boundTransform?.text !== text)
1218
+ node.boundTransform = { text, list: transformList(text) };
1219
+ const list = node.boundTransform.list ?? cascaded;
1220
+ if (list === undefined)
1221
+ delete style['transform'];
1222
+ else
1223
+ style['transform'] = list;
1224
+ return style;
1225
+ }
1226
+ /** A transform string as the list native reads, and none for one CSS cannot read. */
1227
+ const transformOf = (value) => transformList(value) ?? [];
914
1228
  function flattenStyle(value, into) {
915
1229
  if (!value)
916
1230
  return into;
@@ -986,7 +1300,7 @@ function composeTransform(node, props) {
986
1300
  }
987
1301
  else {
988
1302
  // A bound style's transform is still the CSS string: see `inline-transform.ts`.
989
- const own = typeof transform === 'string' ? transformList(transform) : transform;
1303
+ const own = typeof transform === 'string' ? transformOf(transform) : transform;
990
1304
  value = [translate, rotate, scale, own].flatMap((part) => (Array.isArray(part) ? part : []));
991
1305
  node.composedTransform = { parts, value };
992
1306
  }
@@ -1130,7 +1444,7 @@ export class Engine {
1130
1444
  this.structuralSheets = options.globalStyles?.structural === true;
1131
1445
  this.dev = options.dev ?? globalThis.__DEV__ === true;
1132
1446
  if (this.dev) {
1133
- this.styles.onUndefinedToken = (name, props) => this.reportUndefinedToken(name, props);
1447
+ this.styles.onUndefinedToken = (name, props, on) => this.reportUndefinedToken(name, props, on);
1134
1448
  this.styles.onUnreadDisplay = (name, value) => this.reportUnreadDisplay(name, value);
1135
1449
  }
1136
1450
  this.now = options.now ?? (() => globalThis.performance?.now?.() ?? Date.now());
@@ -1285,6 +1599,25 @@ export class Engine {
1285
1599
  * text attributes change, which the same cap does. Android sets it only when `fontFamily` is in
1286
1600
  * the props it is sent, and the engine sends only what changed, so it is sent again once.
1287
1601
  */
1602
+ /**
1603
+ * Loads have settled for these families: every node held back for one, a span inside a
1604
+ * paragraph included, is laid out with the name again, registered or not. See `loadingFamilies`.
1605
+ */
1606
+ fontsSettled(families) {
1607
+ let held = false;
1608
+ const visit = (node) => {
1609
+ for (const child of node.children) {
1610
+ if (child.heldFamily !== undefined && families.has(child.heldFamily)) {
1611
+ this.markProps(child, false);
1612
+ held = true;
1613
+ }
1614
+ visit(child);
1615
+ }
1616
+ };
1617
+ visit(this.root);
1618
+ if (held)
1619
+ this.commit();
1620
+ }
1288
1621
  fontsRegistered(families) {
1289
1622
  const visit = (node) => {
1290
1623
  for (const child of node.children) {
@@ -1396,6 +1729,12 @@ export class Engine {
1396
1729
  viewportSize;
1397
1730
  /** The system text size, from the conditions. See `Conditions.fontScale`. */
1398
1731
  fontScale;
1732
+ viewportWatchers = new Set();
1733
+ /** Calls `listener` after each change of conditions, which is when `viewport` can change. */
1734
+ watchViewport(listener) {
1735
+ this.viewportWatchers.add(listener);
1736
+ return () => this.viewportWatchers.delete(listener);
1737
+ }
1399
1738
  updateConditions(next) {
1400
1739
  this.viewportSize = { width: next.width, height: next.height };
1401
1740
  this.fontScale = next.fontScale;
@@ -1404,11 +1743,19 @@ export class Engine {
1404
1743
  // Committed here rather than left to the next change-detection pass, because there may not
1405
1744
  // be one: no view is dirty, so a tick does no work and never reaches the renderer at all.
1406
1745
  this.commit();
1746
+ for (const watcher of this.viewportWatchers)
1747
+ watcher();
1407
1748
  }
1408
1749
  // --- mutation API -----------------------------------------------------
1409
1750
  createElement(name, sheet = null) {
1410
1751
  const node = new RetainedNode('element', name, this);
1411
1752
  node.sheet = sheet;
1753
+ const html = HTML_ELEMENTS.get(name);
1754
+ if (html !== undefined) {
1755
+ if (html.role)
1756
+ node.props['accessibilityRole'] = html.role;
1757
+ node.styled = html.styled;
1758
+ }
1412
1759
  if (sheet?.structural)
1413
1760
  this.structuralSheets = true;
1414
1761
  if (HOISTS[name])
@@ -1464,7 +1811,7 @@ export class Engine {
1464
1811
  parent.children.push(child);
1465
1812
  if (child.dormantHoists)
1466
1813
  this.wakeHoists(child);
1467
- this.markStructure(parent);
1814
+ this.markStructure(parent, child);
1468
1815
  this.markTextContent(parent, child);
1469
1816
  }
1470
1817
  insertBefore(parent, child, ref) {
@@ -1477,7 +1824,7 @@ export class Engine {
1477
1824
  parent.children.splice(at, 0, child);
1478
1825
  if (child.dormantHoists)
1479
1826
  this.wakeHoists(child);
1480
- this.markStructure(parent);
1827
+ this.markStructure(parent, child);
1481
1828
  this.markTextContent(parent, child);
1482
1829
  }
1483
1830
  removeChild(parent, child) {
@@ -1490,7 +1837,7 @@ export class Engine {
1490
1837
  target.children.splice(at, 1);
1491
1838
  child.parent = null;
1492
1839
  this.removedSinceCommit.add(child);
1493
- this.markStructure(target);
1840
+ this.markStructure(target, child);
1494
1841
  this.markTextContent(target, child);
1495
1842
  }
1496
1843
  /**
@@ -1620,7 +1967,17 @@ export class Engine {
1620
1967
  // Inline style is applied after the cascade and inherited by nothing, so it cannot change
1621
1968
  // what any node matches or inherits. Every other prop can: `[disabled]` is a selector. An
1622
1969
  // inline `direction` is the exception, because the paragraphs below it align by it.
1623
- this.markProps(node, key !== 'style' || this.noteInlineDirection(node));
1970
+ this.markProps(node, key !== 'style' || this.inlineReachesStyle(node));
1971
+ }
1972
+ /**
1973
+ * Whether a node's inline style can change what it or anything below it resolves to: it sets,
1974
+ * or until now set, a property its children inherit.
1975
+ */
1976
+ inlineReachesStyle(node) {
1977
+ const inherits = setsInherited(node.props['style']);
1978
+ const inherited = node.inlineInherits === true;
1979
+ node.inlineInherits = inherits;
1980
+ return this.noteInlineDirection(node) || inherits || inherited;
1624
1981
  }
1625
1982
  /** Whether a node's inline style sets `direction`, remembering that one has. */
1626
1983
  noteInlineDirection(node) {
@@ -1642,7 +1999,26 @@ export class Engine {
1642
1999
  * has changed for the props diff to notice.
1643
2000
  */
1644
2001
  styleChanged(node) {
1645
- this.markProps(node, this.noteInlineDirection(node));
2002
+ this.markProps(node, this.inlineReachesStyle(node));
2003
+ }
2004
+ /**
2005
+ * A bound declaration, `[style.color]`, whose value may be a `var()`. One that is, is kept as a
2006
+ * declaration the cascade settles with the node's tokens, as a rule's is, and true is answered:
2007
+ * the text is no value to put in the node's style. Any other value forgets what was kept.
2008
+ */
2009
+ setBoundStyle(node, prop, value) {
2010
+ const text = typeof value === 'string' && /^\s*var\(/i.test(value) ? value : undefined;
2011
+ const bound = text === undefined ? null : boundDeclaration(prop, tokenFromValue(text), node.name);
2012
+ if (!bound && !node.boundByProp?.has(prop))
2013
+ return false;
2014
+ const byProp = (node.boundByProp ??= new Map());
2015
+ if (bound)
2016
+ byProp.set(prop, bound);
2017
+ else
2018
+ byProp.delete(prop);
2019
+ node.boundStyle = byProp.size ? [...byProp.values()] : null;
2020
+ this.markProps(node);
2021
+ return bound !== null;
1646
2022
  }
1647
2023
  /**
1648
2024
  * Set a custom property on a node, `--tint` from `[style.--tint]`, or remove it with `null`.
@@ -1709,7 +2085,8 @@ export class Engine {
1709
2085
  siblings[i].styleDirty = true;
1710
2086
  }
1711
2087
  }
1712
- markStructure(node) {
2088
+ /** `node`'s child list changed: `moved` came into it or went out of it. */
2089
+ markStructure(node, moved) {
1713
2090
  node.structureDirty = true;
1714
2091
  // A child list that moved changes what its members match, though nothing about them did:
1715
2092
  // the old last row is no longer the last. Only sheets that ask about position pay for this.
@@ -1717,6 +2094,10 @@ export class Engine {
1717
2094
  node.styleDirty = true;
1718
2095
  for (const child of node.children)
1719
2096
  child.styleDirty = true;
2097
+ // `.box:empty + .spacer`: what a later sibling matches can hang on whether this node is
2098
+ // empty, which only its first child arriving or its last one leaving changes.
2099
+ if (wasOrIsEmptyWithout(node, moved))
2100
+ this.markLaterSiblings(node);
1720
2101
  }
1721
2102
  this.markPath(node.parent);
1722
2103
  }
@@ -1780,10 +2161,59 @@ export class Engine {
1780
2161
  }
1781
2162
  this.flushTransitionEvents();
1782
2163
  this.scrollDriver?.afterCommit();
1783
- if (this.facesAdded)
2164
+ // Read first: the commit that settles keyframes starts by clearing it.
2165
+ const facesAdded = this.facesAdded;
2166
+ if (this.awaitingKeyframes.size)
2167
+ this.settleKeyframes();
2168
+ if (facesAdded)
1784
2169
  this.rematchFonts();
1785
2170
  return true;
1786
2171
  }
2172
+ /** The first commit whose new views have not been drawn yet, while `commitUnseen` runs. */
2173
+ unseen = Infinity;
2174
+ /**
2175
+ * A commit for what changed in the same turn as earlier commits, before a frame was drawn.
2176
+ * `since` is how many commits there had been before the first of them (`stats.commits`).
2177
+ *
2178
+ * A view those commits created has not been seen in the style they gave it, so what this one
2179
+ * gives it is where it starts, and no transition runs toward it. That is what a browser
2180
+ * does with a class added to an element in the turn that inserted it, and what `animate.enter`
2181
+ * is built on: Angular adds the enter class after the render pass that created the element, and
2182
+ * takes it off a frame later, which is the one change the transition is for.
2183
+ */
2184
+ commitUnseen(since) {
2185
+ this.unseen = since;
2186
+ try {
2187
+ return this.commit();
2188
+ }
2189
+ finally {
2190
+ this.unseen = Infinity;
2191
+ }
2192
+ }
2193
+ /** Nodes that asked for `@keyframes` this commit had not met, by the name they asked for. */
2194
+ awaitingKeyframes = new Map();
2195
+ /**
2196
+ * A sheet is registered when the first node it styles is committed, so a node committed before
2197
+ * it can name keyframes that arrive later in the same commit. Those nodes play them from another
2198
+ * commit straight away, as a browser applies keyframes however the sheets arrived; a name still
2199
+ * unknown once the commit is over is reported.
2200
+ */
2201
+ settleKeyframes() {
2202
+ const waiting = [...this.awaitingKeyframes];
2203
+ this.awaitingKeyframes.clear();
2204
+ let found = false;
2205
+ for (const [node, name] of waiting) {
2206
+ if (this.keyframes.has(name)) {
2207
+ this.markProps(node, false);
2208
+ found = true;
2209
+ }
2210
+ else if (this.dev) {
2211
+ this.reportMissingKeyframes(name);
2212
+ }
2213
+ }
2214
+ if (found)
2215
+ this.commit();
2216
+ }
1787
2217
  scrollDriver;
1788
2218
  /** Whether native can move a view by a scroll with no JavaScript in between. */
1789
2219
  get drivesScroll() {
@@ -1873,23 +2303,18 @@ export class Engine {
1873
2303
  this.registerSheet(node.hostSheet);
1874
2304
  const props = { ...DEFAULT_PROPS[viewName], ...node.defaultStyle };
1875
2305
  Object.assign(props, this.styles.resolve(node, this.styleEpoch).style);
1876
- for (const key of Object.keys(node.props)) {
1877
- // No native prop has a hyphen. `data-*` and `aria-*` attributes stay on the node for
1878
- // selectors to match, and the components package maps `aria-*` to what native reads.
1879
- if (key !== 'style' &&
1880
- key !== INTRINSIC_SIZE &&
1881
- key !== STYLE_OVERRIDE &&
1882
- !key.includes('-')) {
1883
- props[key] = node.props[key];
1884
- }
1885
- }
2306
+ const resolved = props['pointerEvents'];
2307
+ writeOwnProps(node.props, props);
1886
2308
  withTextContent(node, viewName, props);
1887
- const style = flattenStyle(node.props['style'], props);
2309
+ const cascaded = props['transform'];
2310
+ const style = boundTransform(node, flattenStyle(node.props['style'], props), cascaded);
2311
+ nativePointerEvents(node, style, resolved);
1888
2312
  const intrinsic = node.props[INTRINSIC_SIZE];
1889
2313
  if (intrinsic)
1890
2314
  applyIntrinsicSize(style, intrinsic);
1891
2315
  flattenStyle(node.props[STYLE_OVERRIDE], style);
1892
2316
  this.fontFaces.apply(style);
2317
+ holdLoadingFamily(node, style);
1893
2318
  if (viewName === PARAGRAPH)
1894
2319
  alignText(style, this.directionOf(node, style));
1895
2320
  if (this.fontsRefreshed)
@@ -1981,14 +2406,25 @@ export class Engine {
1981
2406
  if (next && !this.knownSheets.has(next)) {
1982
2407
  this.knownSheets.add(next);
1983
2408
  this.sheetOrder[at] = next;
1984
- if (next.fonts && this.fontFaces.add(next.fonts))
1985
- this.facesAdded = true;
1986
2409
  }
1987
2410
  else {
1988
2411
  this.sheetOrder.splice(at, 1);
1989
2412
  }
1990
2413
  if (sheet.keyframes || next?.keyframes)
1991
2414
  this.reindexKeyframes();
2415
+ if (sheet.fonts || next?.fonts)
2416
+ this.reindexFonts();
2417
+ }
2418
+ /**
2419
+ * Every sheet's `@font-face` rules again, in order, and every text that names a family matched
2420
+ * again: a face whose rule went is no longer matched, though the platform keeps it registered.
2421
+ */
2422
+ reindexFonts() {
2423
+ this.fontFaces.clear();
2424
+ for (const sheet of this.sheetOrder)
2425
+ if (sheet.fonts)
2426
+ this.fontFaces.add(sheet.fonts);
2427
+ this.markFontText(this.root);
1992
2428
  }
1993
2429
  /** Every sheet's `@keyframes` again, in order, and every animation told to look them up. */
1994
2430
  reindexKeyframes() {
@@ -2018,18 +2454,19 @@ export class Engine {
2018
2454
  * Once per sheet that declares faces, so walking the tree costs nothing that matters.
2019
2455
  */
2020
2456
  rematchFonts() {
2021
- const visit = (node) => {
2022
- for (const child of node.children) {
2023
- if (child.kind !== 'element')
2024
- continue;
2025
- if (typeof child.committed?.props['fontFamily'] === 'string')
2026
- this.markProps(child, false);
2027
- visit(child);
2028
- }
2029
- };
2030
- visit(this.root);
2457
+ this.markFontText(this.root);
2031
2458
  this.commit();
2032
2459
  }
2460
+ /** Mark every element under `node` committed with a family, so it is matched again. */
2461
+ markFontText(node) {
2462
+ for (const child of node.children) {
2463
+ if (child.kind !== 'element')
2464
+ continue;
2465
+ if (typeof child.committed?.props['fontFamily'] === 'string')
2466
+ this.markProps(child, false);
2467
+ this.markFontText(child);
2468
+ }
2469
+ }
2033
2470
  knownSheets = new WeakSet();
2034
2471
  /**
2035
2472
  * Start, advance or stop the `@keyframes` animation a node's style asks for.
@@ -2044,6 +2481,7 @@ export class Engine {
2044
2481
  this.stopScrolled(node);
2045
2482
  if (!spec) {
2046
2483
  if (node.playing) {
2484
+ this.cancelPlaying(node);
2047
2485
  node.playing = undefined;
2048
2486
  this.playing.delete(node);
2049
2487
  }
@@ -2051,12 +2489,13 @@ export class Engine {
2051
2489
  }
2052
2490
  const frames = this.keyframes.get(spec.name);
2053
2491
  if (!frames) {
2054
- // Keyframes a hot swap deleted, from under an animation that was playing them.
2492
+ // Keyframes a hot swap deleted, from under an animation that was playing them, or ones a
2493
+ // sheet later in this commit has: see `settleKeyframes`.
2494
+ this.cancelPlaying(node);
2055
2495
  node.playing = undefined;
2056
2496
  this.playing.delete(node);
2057
2497
  this.stopScrolled(node);
2058
- if (this.dev)
2059
- this.reportMissingKeyframes(spec.name);
2498
+ this.awaitingKeyframes.set(node, spec.name);
2060
2499
  return props;
2061
2500
  }
2062
2501
  if (spec.timeline)
@@ -2064,11 +2503,34 @@ export class Engine {
2064
2503
  this.startPlaying(node, spec, frames, props);
2065
2504
  return Object.assign(props, node.playing?.values ?? {});
2066
2505
  }
2506
+ /**
2507
+ * A clock animation the scroll plays instead: the same animation where only the timeline moved,
2508
+ * and one cancelled where the name changed.
2509
+ */
2510
+ handOverToScroll(node, spec) {
2511
+ if (!node.playing)
2512
+ return;
2513
+ if (node.playing.spec.name !== spec.name)
2514
+ this.cancelPlaying(node);
2515
+ node.playing = undefined;
2516
+ this.playing.delete(node);
2517
+ }
2518
+ /**
2519
+ * `animationcancel` for an animation stopped before it ended: the element no longer asks for it,
2520
+ * asks for another by name, or its keyframes went, as a browser fires it.
2521
+ */
2522
+ cancelPlaying(node) {
2523
+ const running = node.playing;
2524
+ if (running && !running.done)
2525
+ this.emitTransition(node, 'topAnimationcancel', running.spec.name);
2526
+ }
2067
2527
  /** Start the clock on an animation, unless the node is already playing this one. */
2068
2528
  startPlaying(node, spec, frames, props) {
2069
2529
  const current = node.playing;
2070
2530
  const inherited = this.inheritedColour(node, frames);
2071
2531
  if (!current || !sameAnimation(current.spec, spec)) {
2532
+ if (current && current.spec.name !== spec.name)
2533
+ this.cancelPlaying(node);
2072
2534
  const started = {
2073
2535
  spec,
2074
2536
  tracks: tracksOf(frames, props, inherited),
@@ -2160,10 +2622,7 @@ export class Engine {
2160
2622
  return Object.assign(props, this.rescrolled(node, current, frames, props, inherited));
2161
2623
  }
2162
2624
  this.stopScrolled(node);
2163
- if (node.playing) {
2164
- node.playing = undefined;
2165
- this.playing.delete(node);
2166
- }
2625
+ this.handOverToScroll(node, spec);
2167
2626
  const tracks = tracksOf(frames, props, inherited);
2168
2627
  const source = this.scrollSourceOf(node);
2169
2628
  const extent = source ? (this.scrollExtents.get(source)?.[spec.timeline] ?? null) : null;
@@ -2280,6 +2739,9 @@ export class Engine {
2280
2739
  `has loaded, so the block has to exist somewhere by the time the animation starts.`);
2281
2740
  }
2282
2741
  transitioned(node, props) {
2742
+ // Nobody saw what the last commit gave a view it created, so this is where the view starts.
2743
+ if (node.bornIn >= this.unseen)
2744
+ node.transitions = undefined;
2283
2745
  const spec = transitionSpec(props);
2284
2746
  if (!spec && !node.transitions)
2285
2747
  return props;
@@ -2391,7 +2853,7 @@ export class Engine {
2391
2853
  if (value === null || value === undefined)
2392
2854
  out[key] = value;
2393
2855
  else if (isColorProp(key))
2394
- out[key] = this.color(value);
2856
+ out[key] = this.painted(key, value);
2395
2857
  // A list of sources has already been resolved by whoever built it, as RN's resolver
2396
2858
  // also assumes: it passes any object through and only turns a number into one.
2397
2859
  else if (ASSET_PROPS.has(key) && !Array.isArray(value))
@@ -2404,7 +2866,7 @@ export class Engine {
2404
2866
  out[key] = this.processFilters(value);
2405
2867
  // A bound style's transform is still the CSS string: see `inline-transform.ts`.
2406
2868
  else if (key === 'transform' && typeof value === 'string')
2407
- out[key] = transformList(value);
2869
+ out[key] = transformOf(value);
2408
2870
  else
2409
2871
  out[key] = value;
2410
2872
  }
@@ -2476,7 +2938,9 @@ export class Engine {
2476
2938
  return node.committed.handle;
2477
2939
  node.styleCommitted = style;
2478
2940
  const viewName = viewNameOf(node);
2941
+ this.forgetRenamed(node, viewName);
2479
2942
  const childHandles = this.reconcileChildren(node, viewName, style);
2943
+ noteTouches(node, style);
2480
2944
  const previous = node.committed;
2481
2945
  this.notePresented(node, viewName);
2482
2946
  if (!previous)
@@ -2495,7 +2959,7 @@ export class Engine {
2495
2959
  handle = this.clone(previous.handle, propsPayload && this.processed(propsPayload), childrenChanged ? childHandles : null);
2496
2960
  // A clone is the same view, so it keeps the tag it was created with, not the newest tag
2497
2961
  // handed out, which is some other node's.
2498
- node.committed = { handle, tag: previous.tag, props, childHandles };
2962
+ node.committed = { handle, tag: previous.tag, props, childHandles, viewName };
2499
2963
  }
2500
2964
  this.clearFlags(node);
2501
2965
  return handle;
@@ -2533,19 +2997,62 @@ export class Engine {
2533
2997
  const context = style?.context;
2534
2998
  if (HOIST_TARGETS.has(node.name))
2535
2999
  return this.reconcileHoistTarget(node, context);
2536
- // A view that takes its text as a prop has no text children to commit. See `ViewNameOptions`.
2537
- const textAsProp = TEXT_CONTENT_PROPS[viewName] !== undefined;
2538
3000
  const handles = [];
2539
3001
  for (const child of node.children) {
2540
3002
  if (child.kind === 'anchor' || this.committedElsewhere(child) || this.withheld(child)) {
2541
3003
  continue;
2542
3004
  }
2543
- if (textAsProp && child.kind === 'text')
3005
+ if (child.kind === 'text') {
3006
+ const handle = this.reconcileText(node, child, viewName, context);
3007
+ if (handle)
3008
+ handles.push(handle);
2544
3009
  continue;
3010
+ }
2545
3011
  handles.push(this.reconcileUnder(node, child, context));
2546
3012
  }
2547
3013
  return handles;
2548
3014
  }
3015
+ /**
3016
+ * The paragraph a run of text written straight into a view is drawn in, or nothing for a run of
3017
+ * whitespace, which a browser draws nothing for between the items of a flex box either.
3018
+ *
3019
+ * It is the text's own, kept on the text node, and no part of the tree: its parent is the view,
3020
+ * so it inherits what the view's text would, and no rule matches it.
3021
+ */
3022
+ /**
3023
+ * A run of text under `parent`, which commits as `viewName`: the text itself inside a text, a
3024
+ * paragraph of its own inside any other view, and nothing where the view takes its text as a
3025
+ * prop (see `ViewNameOptions`) or the run is whitespace between a view's children.
3026
+ */
3027
+ reconcileText(parent, text, viewName, context) {
3028
+ if (TEXT_CONTENT_PROPS[viewName] !== undefined)
3029
+ return null;
3030
+ if (viewName === PARAGRAPH || viewName === VIRTUAL_TEXT) {
3031
+ // Under a text element, the paragraph it had in a view is done with. Under that paragraph
3032
+ // itself, which is where a loose run is committed, it is the one to keep.
3033
+ if (text.box && text.box !== parent)
3034
+ text.box = undefined;
3035
+ return this.reconcileUnder(parent, text, context);
3036
+ }
3037
+ const box = this.looseText(parent, text);
3038
+ return box ? this.reconcileUnder(parent, box, context) : null;
3039
+ }
3040
+ looseText(parent, text) {
3041
+ if (!trimEnds(text.text))
3042
+ return null;
3043
+ let box = text.box;
3044
+ if (!box) {
3045
+ box = new RetainedNode('element', 'text', this);
3046
+ box.anonymous = true;
3047
+ box.children = [text];
3048
+ text.box = box;
3049
+ }
3050
+ box.parent = parent;
3051
+ // The text is not under the box in the tree, so a change to it never marked the box.
3052
+ if (!this.isClean(text))
3053
+ box.subtreeDirty = true;
3054
+ return box;
3055
+ }
2549
3056
  /** Remember an iOS modal host committed as showing, so hiding it waits for `topDismiss`. */
2550
3057
  notePresented(node, viewName) {
2551
3058
  if (viewName !== MODAL_HOST || node.props['visible'] === false)
@@ -2625,6 +3132,14 @@ export class Engine {
2625
3132
  child.committedUnder = parent;
2626
3133
  return handle;
2627
3134
  }
3135
+ /**
3136
+ * A node whose native view is no longer the one it was created as is created again: a text
3137
+ * element that came to hold a view, or stopped. A clone keeps the view it was made as.
3138
+ */
3139
+ forgetRenamed(node, viewName) {
3140
+ if (node.committed && node.committed.viewName !== viewName)
3141
+ this.forgetCommitted(node);
3142
+ }
2628
3143
  /**
2629
3144
  * Let a node and everything under it be created again at the next commit. The views it keeps
2630
3145
  * for hoisted names go too: they are children of the native view it is losing.
@@ -2634,9 +3149,12 @@ export class Engine {
2634
3149
  node.committedUnder = null;
2635
3150
  node.styleCommitted = null;
2636
3151
  node.kept = undefined;
2637
- for (const child of node.children)
2638
- if (child.committed)
3152
+ if (node.box?.committed)
3153
+ this.forgetCommitted(node.box);
3154
+ for (const child of node.children) {
3155
+ if (child.committed || child.box?.committed)
2639
3156
  this.forgetCommitted(child);
3157
+ }
2640
3158
  }
2641
3159
  /** A hoisted node, which commits into an ancestor further up than the parent it is in. */
2642
3160
  committedElsewhere(child) {
@@ -2678,7 +3196,7 @@ export class Engine {
2678
3196
  };
2679
3197
  const payload = diffProps(kept.committed.props, props);
2680
3198
  const handle = this.clone(kept.committed.handle, payload && this.processed(payload), []);
2681
- kept.committed = { handle, tag: kept.committed.tag, props, childHandles: [] };
3199
+ kept.committed = { ...kept.committed, handle, props, childHandles: [] };
2682
3200
  kept.node = null;
2683
3201
  }
2684
3202
  handles.push(kept.committed.handle);
@@ -2747,13 +3265,14 @@ export class Engine {
2747
3265
  const handle = this.fabric.createNode(tag, viewName, this.rootTag, this.processed(props), node);
2748
3266
  for (const child of childHandles)
2749
3267
  this.fabric.appendChild(handle, child);
2750
- node.committed = { handle, tag, props, childHandles };
3268
+ node.committed = { handle, tag, props, childHandles, viewName };
3269
+ node.bornIn = this.stats.commits;
2751
3270
  this.clearFlags(node);
2752
3271
  return handle;
2753
3272
  }
2754
3273
  /**
2755
3274
  * Angular's own unknown-element check does not run in this pipeline, so a `<text>` in a
2756
- * template that never imported `Text` compiles, renders as a plain view, and looks like a
3275
+ * template that never imported `Text` compiles, draws without the component, and looks like a
2757
3276
  * layout bug. On the first commit of any known element with no component behind it, say so.
2758
3277
  */
2759
3278
  checkClaimed(node) {
@@ -2764,7 +3283,8 @@ export class Engine {
2764
3283
  this.reported.add(node.name);
2765
3284
  console.error(`[angular-native] <${node.name}> is used in a template that does not import ` +
2766
3285
  `${className(node.name)}. Add it to the component's \`imports\` from ` +
2767
- `'@ng-native/components'; without it the element renders as a plain view.`);
3286
+ `'@ng-native/components'; without it the element has none of the component's inputs ` +
3287
+ `or behavior, so a prop written on it stays the text it was written as.`);
2768
3288
  }
2769
3289
  /**
2770
3290
  * The misspelt-prop check. Angular's own "Can't bind to" check needs a DOM to run, so a
@@ -2775,11 +3295,13 @@ export class Engine {
2775
3295
  */
2776
3296
  undefinedTokens = new Set();
2777
3297
  /** Said once per name: a phone has no inspector to show the declaration struck out. */
2778
- reportUndefinedToken(name, props) {
3298
+ reportUndefinedToken(name, props, on) {
2779
3299
  if (this.undefinedTokens.has(name))
2780
3300
  return;
2781
3301
  this.undefinedTokens.add(name);
2782
- console.warn(`[angular-native] var(${name}) in ${props.join(', ')} names a custom property nothing in` +
3302
+ // A bound declaration has no rule to be found by, so it says which element it is on.
3303
+ const where = on === undefined ? props.join(', ') : `${props.join(', ')}, bound on <${on}>,`;
3304
+ console.warn(`[angular-native] var(${name}) in ${where} names a custom property nothing in` +
2783
3305
  ` scope defines, so the declaration is dropped, as a browser drops it. Define ${name} on` +
2784
3306
  ` :root or an ancestor, or give the var() a fallback.`);
2785
3307
  }
@@ -3008,6 +3530,18 @@ export class Engine {
3008
3530
  resolveAsset(value) {
3009
3531
  return this.resolveAssetSource(value);
3010
3532
  }
3533
+ /**
3534
+ * A colour prop as native takes it. A shape's `fill` or `stroke` the cascade settled from a
3535
+ * token is the solid brush react-native-svg takes, and `none` no paint at all.
3536
+ */
3537
+ painted(key, value) {
3538
+ if (!BRUSH_PROPS.has(key))
3539
+ return this.color(value);
3540
+ // Already a brush, as the icon wrote it: only one the cascade settled is still text.
3541
+ if (typeof value !== 'string')
3542
+ return value;
3543
+ return value === 'none' ? null : { type: 0, payload: this.color(value) };
3544
+ }
3011
3545
  /**
3012
3546
  * The host's `processColor`, and the one place a platform colour becomes one.
3013
3547
  *