@symbiote-native/engine 0.4.0 → 0.5.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/build/node.js CHANGED
@@ -8,7 +8,7 @@ import { isAriaAliasKey } from './accessibility-props.js';
8
8
  import { isEventFor } from './view-config.js';
9
9
  import { canonicalClassName, isClassNameValue, resolveActiveClassName, resolveClassName, } from './style-registry/index.js';
10
10
  import { dlog } from './debug.js';
11
- import { attachHostBehavior, hasHostBehaviors, markDetachCandidate, ownsListener, reattachHostBehaviors, stashAppListener, } from './host-behavior.js';
11
+ import { appListenerFor, attachHostBehavior, claimModeFor, hasHostBehaviors, markDetachCandidate, notifyChildInserted, notifyOwnedListenerChange, notifyWrapChange, ownsListener, reattachHostBehaviors, derivedNodesOf, slotDerivesFrom, slotPropNameFor, slotTakesChildren, stashAppListener, } from './host-behavior.js';
12
12
  // A cycle, deliberately: commit.ts imports this module for the node shape, and the imperative
13
13
  // methods below call back into it. Neither side touches the other at module-evaluation time -
14
14
  // only inside a function body - so every loader (tsc, vitest, Metro) resolves it fine. The
@@ -17,6 +17,11 @@ import { attachHostBehavior, hasHostBehaviors, markDetachCandidate, ownsListener
17
17
  // release builds (see CLAUDE.md, "Never make correctness depend on a module's load-time side
18
18
  // effect").
19
19
  import { measure as engineMeasure, measureInWindow as engineMeasureInWindow, measureLayout as engineMeasureLayout, setNativeProps as engineSetNativeProps, dispatchViewCommand, } from './commit.js';
20
+ // The same deliberate cycle, for the same reason: `routeProp` resolves an AnimatedNode written
21
+ // into a prop, and the module that owns that resolution reaches back here for `setProp`. See
22
+ // `animated/host-binding.ts`'s header.
23
+ import { hasAnimatedNodes } from './animated/graph.js';
24
+ import { bindAnimatedEvent, bindAnimatedValue, hasAnimatedBindings, reattachAnimatedProps, } from './animated/host-binding.js';
20
25
  const BRAND = Symbol('symbiote.node');
21
26
  // A node carries the Fabric view name directly, so adding a primitive (Image,
22
27
  // ScrollView, TextInput) is just a new string from the adapter, no core change.
@@ -37,6 +42,10 @@ export function isSymbioteEvent(value) {
37
42
  }
38
43
  const FOCUS_COMMAND = 'focus';
39
44
  const BLUR_COMMAND = 'blur';
45
+ // Names and arg order mirror RN's ScrollViewCommands.
46
+ const SCROLL_TO_COMMAND = 'scrollTo';
47
+ const SCROLL_TO_END_COMMAND = 'scrollToEnd';
48
+ const FLASH_SCROLL_INDICATORS_COMMAND = 'flashScrollIndicators';
40
49
  // The one shape every retained node has. A class, not an object literal, for two reasons: the six
41
50
  // imperative methods live on the shared prototype instead of being allocated per node (see
42
51
  // ISymbioteNode above), and both factories below mint the same hidden class.
@@ -75,6 +84,11 @@ class SymbioteNode {
75
84
  // Assigned here for the same hidden-class reason as `hasAriaAlias` above; `attachHostBehavior`
76
85
  // overwrites it a few lines later for the rare node that has a behavior.
77
86
  this.payloadFold = undefined;
87
+ // Same reason again, and here it is load-bearing rather than tidy: the redirect below is read
88
+ // on every append, so the slot must be a stable slot on one hidden class, not a property added
89
+ // to a few nodes after the fact.
90
+ this.childHost = undefined;
91
+ this.wrapper = undefined;
78
92
  }
79
93
  measure(callback) {
80
94
  engineMeasure(this, callback);
@@ -98,6 +112,25 @@ class SymbioteNode {
98
112
  blur() {
99
113
  dispatchViewCommand(this, BLUR_COMMAND, []);
100
114
  }
115
+ // The defaults live HERE and nowhere else. `buildScrollViewHandle`
116
+ // (`@symbiote-native/components`) used to own them and now delegates, so the wrapper's handle and
117
+ // a lowered element's node cannot drift on what `scrollTo()` with no argument means.
118
+ scrollTo(options) {
119
+ const x = options?.x ?? 0;
120
+ const y = options?.y ?? 0;
121
+ const animated = options?.animated ?? true;
122
+ dlog(`ScrollView.scrollTo x=${x} y=${y} animated=${animated}`);
123
+ dispatchViewCommand(this, SCROLL_TO_COMMAND, [x, y, animated]);
124
+ }
125
+ scrollToEnd(options) {
126
+ const animated = options?.animated ?? true;
127
+ dlog(`ScrollView.scrollToEnd animated=${animated}`);
128
+ dispatchViewCommand(this, SCROLL_TO_END_COMMAND, [animated]);
129
+ }
130
+ flashScrollIndicators() {
131
+ dlog('ScrollView.flashScrollIndicators');
132
+ dispatchViewCommand(this, FLASH_SCROLL_INDICATORS_COMMAND, []);
133
+ }
101
134
  }
102
135
  /**
103
136
  * The committed record for `node`, or `undefined` if it has never been committed - or if `node` is
@@ -331,6 +364,22 @@ export function setProp(node, key, value) {
331
364
  // `role` / `aria-*` write in the engine passes through this line.
332
365
  if (!node.hasAriaAlias && isAriaAliasKey(key))
333
366
  node.hasAriaAlias = true;
367
+ // A composed primitive's slot — and its wrapper, where it has one — can carry a value DERIVED
368
+ // from an owner prop, and `markPropsDirty` bubbles up, so neither ever learns. Here rather than
369
+ // in `routeProp` because this is the one choke point every writer passes (a structural adapter's
370
+ // `setProperty` does not go through routeProp), and past the identity guard so a re-render
371
+ // writing an unchanged value costs them nothing. See `IHostBehavior.slotDerived`.
372
+ if (node.childHost !== undefined && slotDerivesFrom(node, key)) {
373
+ markPropsDirty(node.childHost);
374
+ // Past the slot: a `buildStructure` that builds a CHAIN registers the deeper nodes here, and
375
+ // each keeps its own pure fold reading the owner. See `addDerivedNode`.
376
+ const derived = derivedNodesOf(node);
377
+ if (derived !== undefined)
378
+ for (const each of derived)
379
+ markPropsDirty(each);
380
+ if (node.wrapper !== undefined)
381
+ markPropsDirty(node.wrapper);
382
+ }
334
383
  propStats.writes += 1;
335
384
  markPropsDirty(node);
336
385
  }
@@ -371,12 +420,20 @@ const GATED_EVENT_PROPS = new Map([
371
420
  * `setEventListener` diverts an owned name into the stash, which is right for an app listener and
372
421
  * circular for the behavior's own dispatcher — it would stash itself and never occupy the slot it
373
422
  * exists to hold. This is the one writer allowed past that gate.
423
+ *
424
+ * `undefined` removes it, gate flag included. A behavior whose dispatcher is conditional needs
425
+ * that as much as it needs the install: ScrollView takes the owner's `layout` only while the app
426
+ * or an inverted sticky header wants it, and a one-way installer leaves `onLayout: true` standing
427
+ * in the payload of a ScrollView that no longer reads the event.
374
428
  */
375
429
  export function setBehaviorListener(node, name, listener) {
376
- (node.listeners ??= new Map()).set(name, listener);
430
+ if (listener === undefined)
431
+ node.listeners?.delete(name);
432
+ else
433
+ (node.listeners ??= new Map()).set(name, listener);
377
434
  const flagProp = GATED_EVENT_PROPS.get(name);
378
435
  if (flagProp !== undefined)
379
- setProp(node, flagProp, true);
436
+ setProp(node, flagProp, listener === undefined ? undefined : true);
380
437
  }
381
438
  export function setEventListener(node, name, value) {
382
439
  const isHandler = typeof value === 'function';
@@ -388,7 +445,13 @@ export function setEventListener(node, name, value) {
388
445
  // reached the node; lowering removes the mediator. Gated on the boolean first, so an app with no
389
446
  // behavior registered pays one read.
390
447
  if (hasHostBehaviors() && ownsListener(node, name)) {
448
+ // The PRESENCE only, never the identity: listeners deliberately do not notify (a framework
449
+ // hands a fresh closure nearly every render — see `markDirty`'s note on why that must stay
450
+ // free). A flip is a mount-time event, not a per-render one.
451
+ const wasWired = appListenerFor(node, name) !== undefined;
391
452
  stashAppListener(node, name, isHandler ? value : undefined);
453
+ if (wasWired !== isHandler)
454
+ notifyOwnedListenerChange(node, name, isHandler);
392
455
  const flagged = GATED_EVENT_PROPS.get(name);
393
456
  if (flagged !== undefined)
394
457
  setProp(node, flagged, isHandler ? true : undefined);
@@ -635,12 +698,46 @@ const CLASS_PROP_KEYS = new Set(['class', 'className']);
635
698
  export function routeProp(node, key, value) {
636
699
  if (REACT_JSX_DEV_PROPS.has(key))
637
700
  return;
701
+ // The prop twin of the child redirect in `appendChild`. A composed primitive's owner is written
702
+ // with props that belong to its internal slot — `contentContainerStyle` on a ScrollView styles
703
+ // the content view — and the adapter names the OWNER for a prop for the same reason it names the
704
+ // owner for a child: that is where the app wrote it.
705
+ //
706
+ // Gated on the FIELD, so a node with no slot pays one load and one branch and never touches the
707
+ // registry. The redirected write recurses into the slot's own `routeProp`, which is single-hop
708
+ // by construction: a slot has no slot of its own (`childHost` is documented single-hop, and
709
+ // `buildStructure` is what would have to nest one).
710
+ if (node.childHost !== undefined) {
711
+ const slotKey = slotPropNameFor(node, key);
712
+ if (slotKey !== undefined) {
713
+ // A class NAME is a legal spelling of `contentContainerStyle` — every canary writes
714
+ // `contentContainerStyle="scroll-content"` — so a string has to land on the slot as a
715
+ // CLASS. Only the class branch consults the registry; renaming it verbatim would publish a
716
+ // `style` holding a string, which is not a style and is dropped with nothing red. React's
717
+ // wrapper resolves the name itself (components/scroll-view/shared.ts), so this gap could
718
+ // only ever show on the tag path.
719
+ routeProp(node.childHost, slotKey === 'style' && typeof value === 'string' ? 'class' : slotKey, value);
720
+ return;
721
+ }
722
+ }
723
+ // An AnimatedNode written straight into a prop — `<view style={{opacity: value}}/>` — is
724
+ // resolved here into the value to PUBLISH, with the engine holding the subscription. Same
725
+ // shape as the `style` callback below: a value the engine interprets rather than forwards.
726
+ // Returns its input by identity when nothing is animated, so every branch under this line is
727
+ // unchanged. See `animated/host-binding.ts`; the gate is one boolean for an app that animates
728
+ // nothing.
729
+ //
730
+ // AFTER the slot redirect, so an animated `contentContainerStyle` binds on the node that
731
+ // actually carries the style.
732
+ const resolved = hasAnimatedNodes()
733
+ ? bindAnimatedValue(node, key, value)
734
+ : value;
638
735
  if (CLASS_PROP_KEYS.has(key)) {
639
736
  const parts = stylePartsOf(node);
640
737
  // Canonicalised HERE so the stored value is what everything downstream keys on: an all-string
641
738
  // array becomes one string, and then the pressed variant and isAlreadyPublished work on it
642
739
  // exactly as on an authored string. One `typeof` for the common case.
643
- parts.className = canonicalClassName(isClassNameValue(value) ? value : undefined);
740
+ parts.className = canonicalClassName(isClassNameValue(resolved) ? resolved : undefined);
644
741
  parts.classStyle = resolveClassName(parts.className);
645
742
  pushClassStyle(node, parts);
646
743
  return;
@@ -660,13 +757,13 @@ export function routeProp(node, key, value) {
660
757
  //
661
758
  // The callback must be PURE in `pressed`: its result is read once per state, here and under
662
759
  // every transform's emission (`core/components/src/state-style.ts` carries the same contract).
663
- if (isStyleCallback(value)) {
664
- parts.explicitStyle = value({ pressed: false });
665
- parts.activeStyle = value({ pressed: true });
760
+ if (isStyleCallback(resolved)) {
761
+ parts.explicitStyle = resolved({ pressed: false });
762
+ parts.activeStyle = resolved({ pressed: true });
666
763
  parts.activeStyleFromCallback = true;
667
764
  }
668
765
  else {
669
- parts.explicitStyle = value;
766
+ parts.explicitStyle = resolved;
670
767
  // Only a variant WE derived is stale now. `style` switching from a callback to a plain value
671
768
  // must not leave the old pressed look standing, and an `activeStyle` the transform wrote must
672
769
  // survive a `style` write, because the two arrive as independent props in an unspecified
@@ -683,7 +780,7 @@ export function routeProp(node, key, value) {
683
780
  // in the app carries an unknown key to native.
684
781
  if (key === 'activeStyle') {
685
782
  const parts = stylePartsOf(node);
686
- parts.activeStyle = value;
783
+ parts.activeStyle = resolved;
687
784
  // Slot 1 is no longer ours, by definition — whatever a callback derived earlier has just been
688
785
  // replaced. Without this the flag outlives the value it describes: a callback sets it, this
689
786
  // branch overwrites the slot silently, and a later plain `style` then clears a variant the
@@ -695,6 +792,10 @@ export function routeProp(node, key, value) {
695
792
  return;
696
793
  }
697
794
  if (ON_PREFIX.test(key)) {
795
+ // A native-driven `Animated.event` needs the native module as well as the listener map, and
796
+ // registers under the PROP name — see `bindAnimatedEvent`, which no-ops for anything else.
797
+ if (hasAnimatedNodes())
798
+ bindAnimatedEvent(node, key, resolved);
698
799
  const name = listenerName(key);
699
800
  const isRegisteredEvent = RESPONDER_EVENTS.has(name) || isEventFor(node.component, name);
700
801
  // Investigation instrumentation (HeaderOptionsScreen unresponsive-buttons bug): RNS* views
@@ -707,11 +808,11 @@ export function routeProp(node, key, value) {
707
808
  `registered=${isRegisteredEvent} at t=${Date.now()}`);
708
809
  }
709
810
  if (isRegisteredEvent) {
710
- setEventListener(node, name, value);
811
+ setEventListener(node, name, resolved);
711
812
  return;
712
813
  }
713
814
  }
714
- setProp(node, key, value);
815
+ setProp(node, key, resolved);
715
816
  }
716
817
  // The same no-op guard as setProp, and here it is strictly stronger: `text` is a string, so
717
818
  // `Object.is` is a real value comparison rather than the reference check it degrades to for a style
@@ -749,33 +850,183 @@ function detach(child) {
749
850
  parent.children.splice(index, 1);
750
851
  child.parent = undefined;
751
852
  }
752
- export function appendChild(parent, child) {
853
+ // The one place a composed primitive's slot is honoured. See `ISymbioteNode.childHost`: the adapter
854
+ // always names the OWNER, and a node whose behavior built an internal subtree redirects the app's
855
+ // children into it.
856
+ //
857
+ // SINGLE HOP, not a loop, and the field's own comment says why — a chain would put a walk on the
858
+ // engine's hottest path to express a depth no primitive has. A behavior needing depth points
859
+ // `childHost` at the innermost node itself.
860
+ //
861
+ // Reads a field that is `undefined` on every node in every app that registers no composed
862
+ // primitive, so the cost is one load and one branch — deliberately NOT behind `hasHostBehaviors()`,
863
+ // which would be a second read to save nothing.
864
+ // Which node a child actually lands on. See `ISymbioteNode.childHost`: the adapter always names the
865
+ // OWNER, and a node whose behavior built an internal subtree redirects the app's children into it —
866
+ // unless the behavior CLAIMS this particular child, which keeps it on the owner (`claimedChildren`).
867
+ //
868
+ // SINGLE HOP, not a loop, and the field's own comment says why — a chain would put a walk on the
869
+ // engine's hottest path to express a depth no primitive has. A behavior needing depth points
870
+ // `childHost` at the innermost node itself.
871
+ //
872
+ // Reads a field that is `undefined` on every node in every app that registers no composed
873
+ // primitive, so the cost is one load and one branch — deliberately NOT behind `hasHostBehaviors()`,
874
+ // which would be a second read to save nothing. The claim check sits BEHIND that branch, so only a
875
+ // slot-bearing node ever pays the registry probe.
876
+ function hostFor(parent, child) {
877
+ const slot = parent.childHost;
878
+ if (slot === undefined)
879
+ return parent;
880
+ // A slot that is a built SIBLING rather than a container — ImageBackground's absolutely-filled
881
+ // image — keeps the app's children on the owner. See `IHostBehavior.slotTakesNoChildren`.
882
+ if (!slotTakesChildren(parent))
883
+ return parent;
884
+ return claimModeFor(parent, child.component) === undefined ? slot : parent;
885
+ }
886
+ // What actually occupies this node's place in its parent's child list. See `ISymbioteNode.wrapper`:
887
+ // a wrapped owner is what the adapter names and the wrapper is what the tree holds, so every
888
+ // structural op takes the owner and moves the wrapper.
889
+ function placedNode(node) {
890
+ return node.wrapper ?? node;
891
+ }
892
+ // Make `child` the owner's parent, in place. Returns false when this is not a wrap claim, so the
893
+ // two inserts fall through to the ordinary path on one call.
894
+ //
895
+ // The owner being UNATTACHED is the normal case rather than the edge one: every adapter fills a
896
+ // node's children before appending it to its own parent, so the wrap usually happens while
897
+ // `owner.parent` is undefined and the swap below is skipped. The later `appendChild(root, owner)`
898
+ // then inserts the wrapper instead, because `placedNode` says so.
899
+ function wrapsOwner(owner, child) {
900
+ if (owner.childHost === undefined)
901
+ return false;
902
+ if (claimModeFor(owner, child.component) !== 'wrap')
903
+ return false;
904
+ if (hasHostBehaviors())
905
+ reattachHostBehaviors(child);
906
+ if (hasAnimatedBindings())
907
+ reattachAnimatedProps(child);
908
+ detach(child);
909
+ const outerParent = owner.parent;
910
+ if (outerParent !== undefined) {
911
+ markStructureDirty(outerParent);
912
+ outerParent.children[outerParent.children.indexOf(owner)] = child;
913
+ child.parent = outerParent;
914
+ }
915
+ owner.wrapper = child;
916
+ owner.parent = child;
917
+ markStructureDirty(child);
918
+ child.children.push(owner);
919
+ notifyWrapChange(owner, child);
920
+ return true;
921
+ }
922
+ // Put the owner back where its wrapper stood — the mirror of `wrapsOwner`. It must leave the owner
923
+ // ATTACHED: the framework is removing the RefreshControl, not the ScrollView.
924
+ function unwrapsOwner(owner, child) {
925
+ if (owner.wrapper !== child)
926
+ return false;
927
+ const outerParent = child.parent;
928
+ owner.wrapper = undefined;
929
+ if (outerParent !== undefined) {
930
+ markStructureDirty(outerParent);
931
+ outerParent.children[outerParent.children.indexOf(child)] = owner;
932
+ }
933
+ owner.parent = outerParent;
934
+ child.parent = undefined;
935
+ child.children.length = 0;
936
+ notifyWrapChange(owner, undefined);
937
+ return true;
938
+ }
939
+ // Where the child goes in its host's list.
940
+ //
941
+ // A host that STILL has a slot at this point is an owner taking a CLAIMED child, and that child
942
+ // goes before the slot whatever the framework asked for. RN renders `{refreshControl}{content}` in
943
+ // that order, and the node a framework names as `beforeChild` lives inside the slot, so `indexOf`
944
+ // could not find it here anyway.
945
+ function indexFor(host, beforeChild) {
946
+ const slot = host.childHost;
947
+ // A sibling slot is the OPPOSITE placement: RN paints the background image first and the app's
948
+ // children over it (ImageBackground.js:80-102), so they append past it rather than in front of
949
+ // it. Falls through to the ordinary index below, which is what leaves their relative order alone.
950
+ if (slot !== undefined && slotTakesChildren(host))
951
+ return host.children.indexOf(slot);
952
+ // `null` is Solid's spelling of "append"; `undefined` is `appendChild`'s own. Both end up here.
953
+ if (beforeChild === undefined || beforeChild === null)
954
+ return host.children.length;
955
+ const index = host.children.indexOf(beforeChild);
956
+ return index < 0 ? host.children.length : index;
957
+ }
958
+ export function appendChild(requestedParent, child) {
959
+ if (wrapsOwner(requestedParent, child))
960
+ return;
961
+ const parent = hostFor(requestedParent, child);
753
962
  // A node the sweep tore down can be put back — Svelte parks live subtrees offscreen across
754
963
  // commits. A WeakSet miss for anything freshly built, so the create path pays nothing.
755
964
  if (hasHostBehaviors())
756
965
  reattachHostBehaviors(child);
757
- detach(child);
966
+ if (hasAnimatedBindings())
967
+ reattachAnimatedProps(child);
968
+ const placed = placedNode(child);
969
+ detach(placed);
758
970
  markStructureDirty(parent);
759
- child.parent = parent;
760
- parent.children.push(child);
971
+ placed.parent = parent;
972
+ if (parent.childHost !== undefined) {
973
+ parent.children.splice(indexFor(parent, undefined), 0, placed);
974
+ }
975
+ else {
976
+ parent.children.push(placed);
977
+ }
978
+ if (hasHostBehaviors())
979
+ notifyChildInserted(parent, placed);
761
980
  }
762
- export function insertBefore(parent, child, beforeChild) {
981
+ // `beforeChild` is genuinely nullable and the signature used to say otherwise: Solid's renderer
982
+ // spells "append" as `insertBefore(parent, child, null)`, which worked by accident because
983
+ // `indexOf(null)` is -1 and the old fallback appended. Reading a field off it is what made the lie
984
+ // fatal, so the type now says what the callers do.
985
+ export function insertBefore(requestedParent, child, beforeChild) {
986
+ if (wrapsOwner(requestedParent, child))
987
+ return;
988
+ const parent = hostFor(requestedParent, child);
763
989
  if (hasHostBehaviors())
764
990
  reattachHostBehaviors(child);
765
- detach(child);
991
+ if (hasAnimatedBindings())
992
+ reattachAnimatedProps(child);
993
+ const placed = placedNode(child);
994
+ detach(placed);
766
995
  markStructureDirty(parent);
767
- child.parent = parent;
768
- const index = parent.children.indexOf(beforeChild);
769
- parent.children.splice(index < 0 ? parent.children.length : index, 0, child);
996
+ placed.parent = parent;
997
+ parent.children.splice(indexFor(parent, beforeChild === null ? null : placedNode(beforeChild)), 0, placed);
998
+ if (hasHostBehaviors())
999
+ notifyChildInserted(parent, placed);
770
1000
  }
771
1001
  // Removal only NOMINATES a behavior for teardown; the commit sweep decides. A framework may spell
772
1002
  // a move as remove-then-reinsert (Solid does), so tearing down here kills the machine of a node
773
1003
  // that comes back alive in the same batch — see host-behavior.ts's markDetachCandidate.
774
- export function removeChild(parent, child) {
775
- if (hasHostBehaviors())
1004
+ export function removeChild(requestedParent, child) {
1005
+ // Redirected for the same reason the two inserts are: the adapter removes from the node it
1006
+ // appended to, which is the OWNER, while the child actually lives in the slot. Without this the
1007
+ // `indexOf` misses, the splice no-ops, and the child stays committed under the slot forever
1008
+ // while the framework believes it is gone — a leak with nothing red anywhere.
1009
+ //
1010
+ // A wrap claim leaving: the owner takes its own place back and stays in the tree. Nominated for
1011
+ // teardown like any other removed node, because the wrapper IS leaving.
1012
+ if (unwrapsOwner(requestedParent, child)) {
1013
+ if (hasHostBehaviors() || hasAnimatedBindings())
1014
+ markDetachCandidate(child);
1015
+ return;
1016
+ }
1017
+ // A slot that IS the child being removed stops being one. Only a behavior that adopts an APP
1018
+ // child as its slot can reach this (`onChildInserted`); a `buildStructure` slot is internal and
1019
+ // no framework removes it. Without the clear, `hostFor` below redirects the removal INTO the very
1020
+ // node being removed, `indexOf` misses, the splice no-ops, and the child stays committed under a
1021
+ // parent the framework believes it left — and the NEXT child appended nests inside the orphan.
1022
+ if (requestedParent.childHost === child)
1023
+ requestedParent.childHost = undefined;
1024
+ const parent = hostFor(requestedParent, child);
1025
+ if (hasHostBehaviors() || hasAnimatedBindings())
776
1026
  markDetachCandidate(child);
777
1027
  markStructureDirty(parent);
778
- const index = parent.children.indexOf(child);
1028
+ const placed = placedNode(child);
1029
+ const index = parent.children.indexOf(placed);
779
1030
  if (index >= 0)
780
1031
  parent.children.splice(index, 1);
781
1032
  child.parent = undefined;
@@ -29,7 +29,7 @@ export interface IPanResponderCallbacks {
29
29
  onPanResponderTerminationRequest?: IActiveCallback;
30
30
  onShouldBlockNativeResponder?: IActiveCallback;
31
31
  }
32
- export interface IGestureResponderHandlers {
32
+ export type IGestureResponderHandlers = {
33
33
  onStartShouldSetResponder: (event: ISymbioteEvent) => boolean;
34
34
  onStartShouldSetResponderCapture: (event: ISymbioteEvent) => boolean;
35
35
  onMoveShouldSetResponder: (event: ISymbioteEvent) => boolean;
@@ -42,7 +42,7 @@ export interface IGestureResponderHandlers {
42
42
  onResponderRelease: (event: ISymbioteEvent) => void;
43
43
  onResponderTerminate: (event: ISymbioteEvent) => void;
44
44
  onResponderTerminationRequest: (event: ISymbioteEvent) => boolean;
45
- }
45
+ };
46
46
  export interface IPanResponderInstance {
47
47
  panHandlers: IGestureResponderHandlers;
48
48
  getInteractionHandle: () => number | null;
@@ -250,9 +250,11 @@ const PanResponder = {
250
250
  };
251
251
  const panHandlers = {
252
252
  onStartShouldSetResponder(event) {
253
- return config.onStartShouldSetPanResponder === undefined
253
+ const wants = config.onStartShouldSetPanResponder === undefined
254
254
  ? false
255
255
  : config.onStartShouldSetPanResponder(event, gestureState);
256
+ dlog(`PanResponder startShouldSet -> ${wants}`);
257
+ return wants;
256
258
  },
257
259
  onMoveShouldSetResponder(event) {
258
260
  return config.onMoveShouldSetPanResponder === undefined
@@ -320,12 +322,16 @@ const PanResponder = {
320
322
  },
321
323
  onResponderMove(event) {
322
324
  const touches = readTouches(event);
323
- // Same duplicate-frame guard as the capture path.
324
- if (gestureState._accountsForMovesUpTo ===
325
- frameTimestampOf(event, touches)) {
325
+ const frame = frameTimestampOf(event, touches);
326
+ // Same duplicate-frame guard as the capture path. Logged on BOTH sides: a frame
327
+ // stamp that never advances swallows every move while the handlers themselves keep
328
+ // firing, so the app looks inert with nothing to see from the outside.
329
+ if (gestureState._accountsForMovesUpTo === frame) {
330
+ dlog(`PanResponder move SWALLOWED frame=${frame} touches=${touches.length} history=${touchHistoryOf(event) === undefined ? 'none' : 'yes'}`);
326
331
  return;
327
332
  }
328
333
  updateGestureStateOnMove(gestureState, event, touches);
334
+ dlog(`PanResponder move frame=${frame} dx=${gestureState.dx} dy=${gestureState.dy}`);
329
335
  config.onPanResponderMove?.(event, gestureState);
330
336
  },
331
337
  onResponderEnd(event) {
package/build/styles.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { AnimatedNode } from './animated/graph';
1
2
  import type { IColorValue } from './platform-color';
2
3
  export type IDimensionValue = number | string;
3
4
  export type IFlexAlign = 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline';
@@ -223,7 +224,10 @@ export interface ITextStyle extends IViewStyle {
223
224
  }
224
225
  type IStyleFalsy = false | null | undefined | '';
225
226
  type IRecursiveArray<T> = ReadonlyArray<T | IRecursiveArray<T>>;
226
- export type IStyleProp<T> = T | IStyleFalsy | IRecursiveArray<T | IStyleFalsy>;
227
+ type IWithAnimated<T> = T extends AnimatedNode ? T : T extends string | number | boolean | null | undefined ? T | AnimatedNode : T extends ReadonlyArray<infer TItem> ? ReadonlyArray<IWithAnimated<TItem>> : T extends object ? {
228
+ [K in keyof T]: IWithAnimated<T[K]>;
229
+ } : T;
230
+ export type IStyleProp<T> = IWithAnimated<T> | IStyleFalsy | IRecursiveArray<IWithAnimated<T> | IStyleFalsy>;
227
231
  export type INamedStyles<T> = {
228
232
  [P in keyof T]: IViewStyle | ITextStyle;
229
233
  };
package/build/surface.js CHANGED
@@ -5,6 +5,8 @@ import { commitChildren } from './commit.js';
5
5
  import { dlog } from './debug.js';
6
6
  import { installEventHandler } from './events/index.js';
7
7
  import { markStructureDirty } from './node.js';
8
+ import { hasHostBehaviors, markDetachCandidate } from './host-behavior.js';
9
+ import { hasAnimatedBindings } from './animated/host-binding.js';
8
10
  export class SymbioteSurface {
9
11
  rootTag;
10
12
  children = [];
@@ -23,12 +25,24 @@ export class SymbioteSurface {
23
25
  const index = this.children.indexOf(beforeChild);
24
26
  this.children.splice(index < 0 ? this.children.length : index, 0, child);
25
27
  }
28
+ // Nominates for teardown exactly as `node.ts`'s `removeChild` does, and for the same reason it
29
+ // only NOMINATES: a framework may spell a move as remove-then-reinsert, so the commit sweep
30
+ // decides. Without this the surface is the one removal path that never reaches the sweep, and a
31
+ // node's behavior — its timers included — outlives the surface with nothing red.
32
+ //
33
+ // `detach` above is deliberately NOT nominated: the two inserts call it to reposition a child
34
+ // that is staying.
26
35
  removeChild(child) {
27
36
  const index = this.children.indexOf(child);
28
37
  if (index >= 0)
29
38
  this.children.splice(index, 1);
39
+ if (hasHostBehaviors() || hasAnimatedBindings())
40
+ markDetachCandidate(child);
30
41
  }
31
42
  clear() {
43
+ if (hasHostBehaviors() || hasAnimatedBindings())
44
+ for (const child of this.children)
45
+ markDetachCandidate(child);
32
46
  this.children.length = 0;
33
47
  }
34
48
  // Synchronous commit: used by React's resetAfterCommit, which already
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/engine",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "SymbioteNative's retained shadow-tree engine — clone-on-write commit path + event normalization over React Native Fabric, shared by every framework adapter.",
5
5
  "license": "MIT",
6
6
  "repository": {