@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/accessibility-props.d.ts +1 -0
- package/build/accessibility-props.js +9 -5
- package/build/animated/graph.d.ts +2 -0
- package/build/animated/graph.js +14 -0
- package/build/animated/host-binding.d.ts +39 -0
- package/build/animated/host-binding.js +263 -0
- package/build/animated/leaf-lifecycle.js +10 -22
- package/build/commit.js +47 -19
- package/build/events/index.js +49 -7
- package/build/fabric-props.js +16 -1
- package/build/fabric.d.ts +1 -0
- package/build/fabric.js +8 -0
- package/build/host-behavior.d.ts +38 -1
- package/build/host-behavior.js +128 -16
- package/build/index.d.ts +6 -5
- package/build/index.js +16 -4
- package/build/node.d.ts +20 -4
- package/build/node.js +274 -23
- package/build/pan-responder/index.d.ts +2 -2
- package/build/pan-responder/index.js +10 -4
- package/build/styles.d.ts +5 -1
- package/build/surface.js +14 -0
- package/package.json +1 -1
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
|
-
(
|
|
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(
|
|
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(
|
|
664
|
-
parts.explicitStyle =
|
|
665
|
-
parts.activeStyle =
|
|
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 =
|
|
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 =
|
|
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,
|
|
811
|
+
setEventListener(node, name, resolved);
|
|
711
812
|
return;
|
|
712
813
|
}
|
|
713
814
|
}
|
|
714
|
-
setProp(node, key,
|
|
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
|
-
|
|
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
|
-
|
|
966
|
+
if (hasAnimatedBindings())
|
|
967
|
+
reattachAnimatedProps(child);
|
|
968
|
+
const placed = placedNode(child);
|
|
969
|
+
detach(placed);
|
|
758
970
|
markStructureDirty(parent);
|
|
759
|
-
|
|
760
|
-
parent.
|
|
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
|
-
|
|
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
|
-
|
|
991
|
+
if (hasAnimatedBindings())
|
|
992
|
+
reattachAnimatedProps(child);
|
|
993
|
+
const placed = placedNode(child);
|
|
994
|
+
detach(placed);
|
|
766
995
|
markStructureDirty(parent);
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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(
|
|
775
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|