@symbiote-native/angular 0.3.3 → 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.
Files changed (69) hide show
  1. package/babel-register-composed.cjs +119 -0
  2. package/build/angular/components/image/shared.d.ts +4 -4
  3. package/build/angular/components/image/shared.js +1 -3
  4. package/build/angular/components/image/shared.js.map +1 -1
  5. package/build/angular/components/input-accessory-view.d.ts +1 -1
  6. package/build/angular/components/input-accessory-view.js +1 -5
  7. package/build/angular/components/input-accessory-view.js.map +1 -1
  8. package/build/angular/components/keyboard-avoiding-view.js +1 -6
  9. package/build/angular/components/keyboard-avoiding-view.js.map +1 -1
  10. package/build/angular/components/modal.js +1 -6
  11. package/build/angular/components/modal.js.map +1 -1
  12. package/build/angular/components/pressable/index.js +13 -14
  13. package/build/angular/components/pressable/index.js.map +1 -1
  14. package/build/angular/components/refresh-control.js +1 -6
  15. package/build/angular/components/refresh-control.js.map +1 -1
  16. package/build/angular/components/safe-area-view.d.ts +1 -1
  17. package/build/angular/components/safe-area-view.js +1 -5
  18. package/build/angular/components/safe-area-view.js.map +1 -1
  19. package/build/angular/components/text-input.js +1 -6
  20. package/build/angular/components/text-input.js.map +1 -1
  21. package/build/angular/components/touchable-native-feedback.js +1 -6
  22. package/build/angular/components/touchable-native-feedback.js.map +1 -1
  23. package/build/angular/index.d.ts +1 -1
  24. package/build/angular/index.js +1 -1
  25. package/build/angular/index.js.map +1 -1
  26. package/build/angular/modules/animated/animated-leaf-binder.d.ts +14 -0
  27. package/build/angular/modules/animated/animated-leaf-binder.js +91 -0
  28. package/build/angular/modules/animated/animated-leaf-binder.js.map +1 -0
  29. package/build/angular/modules/animated/create-animated-component.d.ts +3 -12
  30. package/build/angular/modules/animated/create-animated-component.js +48 -113
  31. package/build/angular/modules/animated/create-animated-component.js.map +1 -1
  32. package/build/angular/renderer.js +6 -14
  33. package/build/angular/renderer.js.map +1 -1
  34. package/build/components/image/shared.d.ts +4 -4
  35. package/build/components/image/shared.js +1 -3
  36. package/build/components/input-accessory-view.d.ts +1 -1
  37. package/build/components/input-accessory-view.js +1 -5
  38. package/build/components/keyboard-avoiding-view.js +1 -6
  39. package/build/components/modal.js +1 -6
  40. package/build/components/pressable/index.js +13 -14
  41. package/build/components/refresh-control.js +1 -6
  42. package/build/components/safe-area-view.d.ts +1 -1
  43. package/build/components/safe-area-view.js +1 -5
  44. package/build/components/text-input.js +1 -6
  45. package/build/components/touchable-native-feedback.js +1 -6
  46. package/build/index.d.ts +1 -1
  47. package/build/index.js +1 -1
  48. package/build/modules/animated/animated-leaf-binder.d.ts +14 -0
  49. package/build/modules/animated/animated-leaf-binder.js +90 -0
  50. package/build/modules/animated/create-animated-component.d.ts +3 -12
  51. package/build/modules/animated/create-animated-component.js +47 -112
  52. package/build/renderer.js +6 -14
  53. package/package.json +8 -4
  54. package/src/components/image/shared.ts +6 -5
  55. package/src/components/input-accessory-view.ts +6 -7
  56. package/src/components/keyboard-avoiding-view.ts +1 -6
  57. package/src/components/modal.ts +1 -6
  58. package/src/components/pressable/index.ts +17 -15
  59. package/src/components/refresh-control.ts +1 -6
  60. package/src/components/safe-area-view.ts +6 -7
  61. package/src/components/text-input.ts +1 -6
  62. package/src/components/touchable-native-feedback.ts +1 -7
  63. package/src/index.ts +1 -1
  64. package/src/modules/animated/animated-leaf-binder.test.ts +116 -0
  65. package/src/modules/animated/animated-leaf-binder.ts +101 -0
  66. package/src/modules/animated/animated-scroll-view.test.ts +17 -0
  67. package/src/modules/animated/create-animated-component.ts +53 -123
  68. package/src/renderer.test.ts +24 -1
  69. package/src/renderer.ts +6 -14
@@ -0,0 +1,116 @@
1
+ // Unit tests for AnimatedLeafBinder in isolation — no Angular involved. Both
2
+ // AnimatedComponentBase and AnimatedImage delegate their leaf-lifecycle (build a leaf,
3
+ // bind it to the committed Fabric node, swap leaves on re-render, tear down) to this Pure
4
+ // Fabrication; these tests exercise the binder directly against a real engine node
5
+ // (SymbioteSurface + the fake Fabric backend), so a break here isolates to the shared
6
+ // orchestration rather than either owning component.
7
+ import { beforeEach, describe, expect, it } from 'vitest';
8
+ import { installFabric } from '@symbiote-native/test-utils';
9
+ import {
10
+ AnimatedValue,
11
+ createElement,
12
+ createSurface,
13
+ type ISymbioteNode,
14
+ } from '@symbiote-native/engine';
15
+ import { AnimatedLeafBinder } from './animated-leaf-binder';
16
+
17
+ const fabric = installFabric();
18
+ let nextRootTag = 9001;
19
+
20
+ beforeEach(() => fabric.reset());
21
+
22
+ // A node that has already gone through a commit — whenCommitted fires synchronously.
23
+ function committedNode(): ISymbioteNode {
24
+ const surface = createSurface(nextRootTag++);
25
+ const node = createElement('RCTView');
26
+ surface.appendChild(node);
27
+ surface.commit();
28
+ return node;
29
+ }
30
+
31
+ // A node that is attached to a surface but has NOT been committed yet — whenCommitted
32
+ // must defer until the caller commits the surface.
33
+ function uncommittedNode(): { node: ISymbioteNode; commit: () => void } {
34
+ const surface = createSurface(nextRootTag++);
35
+ const node = createElement('RCTView');
36
+ surface.appendChild(node);
37
+ return { node, commit: () => surface.commit() };
38
+ }
39
+
40
+ // `appRoot()` unwraps RN's synthetic box-none AppContainer, which is ALSO an RCTView, so a
41
+ // plain `fabric.find(viewName === 'RCTView')` would match it instead of the node under test.
42
+ // Each test appends exactly one child, so the container's first (only) child is the real node.
43
+ function fakeView(): ReturnType<typeof fabric.find> {
44
+ return fabric.appRoot().children[0];
45
+ }
46
+
47
+ describe('AnimatedLeafBinder', () => {
48
+ it('creates and attaches an AnimatedProps leaf, wiring value changes onto the host node', () => {
49
+ const node = committedNode();
50
+ const binder = new AnimatedLeafBinder(() => node, 'test');
51
+ const opacity = new AnimatedValue(1);
52
+
53
+ // The leaf itself never pushes the INITIAL value — that first paint comes from the
54
+ // owning component's own reduceProps template binding (out of scope here). The leaf
55
+ // only drives value CHANGES after it is bound.
56
+ binder.reconcile({ style: { opacity } }, false);
57
+
58
+ opacity.setValue(0.4);
59
+ expect(fakeView()?.props.opacity).toBe(0.4);
60
+ });
61
+
62
+ it('binds to the host node via whenCommitted, deferring until the node is actually committed', () => {
63
+ const { node, commit } = uncommittedNode();
64
+ const binder = new AnimatedLeafBinder(() => node, 'test');
65
+ const opacity = new AnimatedValue(1);
66
+
67
+ binder.reconcile({ style: { opacity } }, false);
68
+ // Not committed yet: the value graph exists, but no Fabric node to flush onto.
69
+ opacity.setValue(0.5);
70
+ expect(fabric.created).toHaveLength(0);
71
+
72
+ commit();
73
+ // whenCommitted's post-commit retry binds the leaf now; a subsequent change flushes.
74
+ opacity.setValue(0.7);
75
+ expect(fakeView()?.props.opacity).toBe(0.7);
76
+ });
77
+
78
+ it('attaches the new leaf before detaching the old one on a second reconcile', () => {
79
+ const node = committedNode();
80
+ const binder = new AnimatedLeafBinder(() => node, 'test');
81
+ const shared = new AnimatedValue(1);
82
+ const order: string[] = [];
83
+ const originalAddChild = shared.__addChild.bind(shared);
84
+ const originalRemoveChild = shared.__removeChild.bind(shared);
85
+ shared.__addChild = child => {
86
+ order.push('add');
87
+ originalAddChild(child);
88
+ };
89
+ shared.__removeChild = child => {
90
+ order.push('remove');
91
+ originalRemoveChild(child);
92
+ };
93
+
94
+ binder.reconcile({ style: { opacity: shared } }, false);
95
+ order.length = 0; // only care about the SECOND reconcile's swap order
96
+
97
+ binder.reconcile({ style: { opacity: shared } }, false);
98
+
99
+ expect(order).toEqual(['add', 'remove']);
100
+ });
101
+
102
+ it('detaches the leaf and cancels any pending bind on destroy', () => {
103
+ const { node, commit } = uncommittedNode();
104
+ const binder = new AnimatedLeafBinder(() => node, 'test');
105
+ const opacity = new AnimatedValue(1);
106
+
107
+ binder.reconcile({ style: { opacity } }, false);
108
+ binder.destroy();
109
+ commit();
110
+
111
+ // The pending whenCommitted bind was cancelled by destroy, so committing afterward
112
+ // must not retroactively bind the (already-detached) leaf.
113
+ opacity.setValue(0.9);
114
+ expect(fakeView()?.props.opacity).toBeUndefined();
115
+ });
116
+ });
@@ -0,0 +1,101 @@
1
+ // Pure Fabrication (GRASP): the leaf-lifecycle orchestration shared by every Animated*
2
+ // wrapper, extracted out of AnimatedComponentBase so AnimatedImage — which cannot extend
3
+ // AnimatedComponentBase, since it must extend ImageBase for TS's-native image handling —
4
+ // gets the SAME logic via composition instead of copy-pasting it. Framework-thin on
5
+ // purpose: it knows nothing about Angular (@ViewChild, DI, change detection), only the
6
+ // engine's Animated leaf + commit primitives, so it stays testable without any Angular
7
+ // bootstrap. NOTE: react/vue drive this same dance through their own runtime HOC
8
+ // (createAnimatedComponent) rather than a class, so there is no cross-adapter dedup
9
+ // opportunity here yet — this Pure Fabrication is local to the angular adapter.
10
+ import {
11
+ AnimatedProps,
12
+ attachNativeEventHandler,
13
+ dlog,
14
+ whenCommitted,
15
+ type ISymbioteNode,
16
+ } from '@symbiote-native/engine';
17
+
18
+ export class AnimatedLeafBinder {
19
+ // The leaf currently wired into the value graph. Null until the first reconcile.
20
+ private attached: AnimatedProps | null = null;
21
+ // The whenCommitted waiter for the current leaf's node binding, cancelled before the next.
22
+ private cancelBind: (() => void) | undefined;
23
+ // Detachers for any Animated.event prop bound natively to the committed node (JS path: none).
24
+ private eventDetachers: Array<() => void> = [];
25
+
26
+ // `resolveNode` stays a caller-supplied resolver (not a constructor-captured node) so this
27
+ // class never needs to know HOW the host node is found — AnimatedComponentBase and
28
+ // AnimatedImage both resolve it the same way (resolveHostNode + isSymbioteNode over their
29
+ // own @ViewChild), but that's Angular DI state this binder has no business touching.
30
+ // `label` is purely diagnostic (dlog), mirroring the constructor.name the pre-extraction
31
+ // dlog call used.
32
+ constructor(
33
+ private readonly resolveNode: () => ISymbioteNode | null,
34
+ private readonly label: string,
35
+ ) {}
36
+
37
+ // Build a fresh AnimatedProps leaf from the current props (the React/Vue per-render
38
+ // useMemo(rest)), wire it into the graph, and swap the previous one out. Attach the NEW leaf
39
+ // BEFORE detaching the OLD one: a shared Value self-detaches (dropping its native node) the
40
+ // instant its child count hits zero, so detaching first would kill a running native animation
41
+ // on any unrelated re-render (mirrors RN's AnimatedComponent._attachProps). Then bind the
42
+ // committed node, go native if wanted, and rebind native events.
43
+ reconcile(props: Record<string, unknown>, wantsNative: boolean): void {
44
+ dlog(`${this.label} reconcile`);
45
+ const newLeaf = new AnimatedProps(props);
46
+ newLeaf.__attach();
47
+ if (this.attached !== null && this.attached !== newLeaf) this.attached.__detach();
48
+ this.attached = newLeaf;
49
+
50
+ this.bindNode(newLeaf, props, wantsNative);
51
+ }
52
+
53
+ // Bind the leaf to the host's Fabric node THROUGH whenCommitted: under Angular's zoneless
54
+ // batched change detection the inner host's tag does not exist yet at ngAfterViewInit time
55
+ // (the same async-commit gotcha Vue documents), so binding eagerly would no-op. whenCommitted
56
+ // runs the action now if already committed, else after the commit that assigns the tag.
57
+ private bindNode(
58
+ leaf: AnimatedProps,
59
+ props: Record<string, unknown>,
60
+ wantsNative: boolean,
61
+ ): void {
62
+ this.cancelBind?.();
63
+ this.cancelBind = undefined;
64
+ const node = this.resolveNode();
65
+ if (node === null) return;
66
+ this.cancelBind = whenCommitted(node, () => {
67
+ leaf.setNativeView(node);
68
+ if (wantsNative) leaf.__makeNative();
69
+ this.attachEvents(node, props);
70
+ });
71
+ }
72
+
73
+ // Native-attach any Animated.event prop (e.g. onScroll={Animated.event(…,{useNativeDriver:true})})
74
+ // to the committed node. attachNativeEventHandler no-ops (returns undefined) unless the prop is a
75
+ // native event handler with a committed tag, so the JS path stays the fallback. Rebound each
76
+ // reconcile so a new inline event re-attaches; detached first to avoid leaking the prior binding.
77
+ private attachEvents(node: ISymbioteNode, props: Record<string, unknown>): void {
78
+ this.detachEvents();
79
+ for (const key of Object.keys(props)) {
80
+ const attachment = attachNativeEventHandler(node, key, props[key]);
81
+ if (attachment !== undefined) this.eventDetachers.push(attachment.detach);
82
+ }
83
+ }
84
+
85
+ private detachEvents(): void {
86
+ for (const detach of this.eventDetachers) detach();
87
+ this.eventDetachers = [];
88
+ }
89
+
90
+ // Tear down everything: cancel a still-pending bind, drop native event listeners, detach the
91
+ // current leaf from the value graph. Safe to call even if reconcile() was never called.
92
+ destroy(): void {
93
+ this.cancelBind?.();
94
+ this.cancelBind = undefined;
95
+ this.detachEvents();
96
+ if (this.attached !== null) {
97
+ this.attached.__detach();
98
+ this.attached = null;
99
+ }
100
+ }
101
+ }
@@ -75,6 +75,23 @@ describe('AnimatedScrollView', () => {
75
75
  expect(content?.children.map(child => child.props.testID)).toEqual(['a', 'b']);
76
76
  });
77
77
 
78
+ // Regression test for a THIRD bug in this same bespoke-template class, this one iOS-only (the
79
+ // inverse of the two Android bugs above): AnimatedScrollView never applied
80
+ // selectScrollIntrinsics' scrollViewBaseStyle (overflow: 'scroll') to its host node, unlike the
81
+ // real ScrollView component. On iOS Fabric a scroll view only clips its content to its own
82
+ // frame when `overflow: 'scroll'` is set; without it, content taller than the frame bleeds out
83
+ // over sibling views instead of scrolling clipped (Android's native ViewGroup clips regardless
84
+ // of the style prop, which is why this was invisible there). See
85
+ // core/components/src/view/render-scroll-view.ts's SCROLL_VIEW_BASE_VERTICAL comment.
86
+ it('applies the scroll-view base style (overflow: scroll) so content clips to the frame', async () => {
87
+ mount(ROOT_TAG, AnimatedScrollViewApp);
88
+ await tick();
89
+
90
+ const scrollView = fabric.find(node => node.viewName === 'RCTScrollView');
91
+ expect(scrollView?.props.overflow).toBe('scroll');
92
+ expect(scrollView?.props.flexDirection).toBe('column');
93
+ });
94
+
78
95
  it('lets an explicit nestedScrollEnabled in animatedProps override the default', async () => {
79
96
  mount(OVERRIDE_ROOT_TAG, AnimatedScrollViewOverrideApp);
80
97
  await tick();
@@ -32,17 +32,14 @@ import {
32
32
  type Type,
33
33
  } from '@angular/core';
34
34
  import {
35
- AnimatedProps,
36
- attachNativeEventHandler,
37
- dlog,
38
35
  isNativeAnimatedAvailable,
39
36
  isSymbioteNode,
40
37
  reduceProps,
41
38
  readPassthroughStyle,
42
39
  resolveHostNode,
43
- whenCommitted,
44
40
  type ISymbioteNode,
45
41
  } from '@symbiote-native/engine';
42
+ import { selectScrollIntrinsics } from '@symbiote-native/components';
46
43
  import { Image, ScrollView, Text, View } from '../../components';
47
44
  import {
48
45
  anchorHostStyle,
@@ -60,6 +57,7 @@ import {
60
57
  resolveImageProps,
61
58
  } from '../../components/image/shared';
62
59
  import { SectionList } from '../../components/section-list';
60
+ import { AnimatedLeafBinder } from './animated-leaf-binder';
63
61
 
64
62
  // RN's prop carrying explicit (already-rasterized) values that override the animated prop in
65
63
  // the COMMITTED props (sticky-header passthrough). Named once so the directive input and the
@@ -96,12 +94,14 @@ export abstract class AnimatedComponentBase implements AfterViewInit, OnChanges,
96
94
  // the leaf binds to. Inherited by the decorated subclass (Angular collects base-class queries).
97
95
  @ViewChild(SymbioteHostPropsDirective) private hostDirective?: SymbioteHostPropsDirective;
98
96
 
99
- // The leaf currently wired into the value graph. Null until the first reconcile.
100
- private attached: AnimatedProps | null = null;
101
- // The whenCommitted waiter for the current leaf's node binding, cancelled before the next.
102
- private cancelBind: (() => void) | undefined;
103
- // Detachers for any Animated.event prop bound natively to the committed node (JS path: none).
104
- private eventDetachers: Array<() => void> = [];
97
+ // The leaf-lifecycle orchestration (build/attach/swap/bind/detach an AnimatedProps leaf) is a
98
+ // Pure Fabrication shared with AnimatedImage, which cannot extend this class (it must extend
99
+ // ImageBase see the file header). `resolveNode` stays a resolver, not a captured node: the
100
+ // committed node only exists after `hostDirective` is populated post-view-init.
101
+ private readonly leafBinder = new AnimatedLeafBinder(
102
+ () => this.hostNode(),
103
+ this.constructor.name,
104
+ );
105
105
  // Set once the view (and the inner host directive) exists; ngOnChanges before then is a no-op.
106
106
  private viewReady = false;
107
107
 
@@ -129,78 +129,22 @@ export abstract class AnimatedComponentBase implements AfterViewInit, OnChanges,
129
129
  }
130
130
 
131
131
  ngOnDestroy(): void {
132
- this.cancelBind?.();
133
- this.cancelBind = undefined;
134
- this.detachEvents();
135
- if (this.attached !== null) {
136
- this.attached.__detach();
137
- this.attached = null;
138
- }
132
+ this.leafBinder.destroy();
139
133
  }
140
134
 
141
- // Build a fresh AnimatedProps leaf from the current props (the React/Vue per-render
142
- // useMemo(rest)), wire it into the graph, and swap the previous one out. Attach the NEW leaf
143
- // BEFORE detaching the OLD one: a shared Value self-detaches (dropping its native node) the
144
- // instant its child count hits zero, so detaching first would kill a running native animation
145
- // on any unrelated re-render (mirrors RN's AnimatedComponent._attachProps). Then bind the
146
- // committed node, go native if wanted, and rebind native events.
135
+ // A non-stable [animatedProps]/[style] input (a fresh object literal in the template) pushes
136
+ // a new reference on every change-detection pass of the HOST view, re-triggering this on every
137
+ // unrelated interaction elsewhere in the app DEBUG=1 turns this into a visible tick count in
138
+ // AnimatedLeafBinder.reconcile, so an unexpectedly high rate for a component nobody is
139
+ // animating is the tell (see AnimatedScrollView's stable-reference fix in examples/angular/App.ts).
147
140
  private reconcile(): void {
148
141
  if (!this.viewReady) return;
149
- // A non-stable [animatedProps]/[style] input (a fresh object literal in the template)
150
- // pushes a new reference on every change-detection pass of the HOST view, re-triggering
151
- // this on every unrelated interaction elsewhere in the app — DEBUG=1 turns this into a
152
- // visible tick count, so an unexpectedly high rate for a component nobody is animating
153
- // is the tell (see AnimatedScrollView's stable-reference fix in examples/angular/App.ts).
154
- dlog(`AnimatedComponentBase reconcile (${this.constructor.name})`);
155
142
  const props = this.mergedProps();
156
143
  const hasPassthroughAnimatedValues =
157
144
  this.passthroughAnimatedPropExplicitValues !== null &&
158
145
  this.passthroughAnimatedPropExplicitValues !== undefined;
159
146
  const wantsNative = hasPassthroughAnimatedValues && isNativeAnimatedAvailable();
160
-
161
- const newLeaf = new AnimatedProps(props);
162
- newLeaf.__attach();
163
- if (this.attached !== null && this.attached !== newLeaf) this.attached.__detach();
164
- this.attached = newLeaf;
165
-
166
- this.bindNode(newLeaf, props, wantsNative);
167
- }
168
-
169
- // Bind the leaf to the host's Fabric node THROUGH whenCommitted: under Angular's zoneless
170
- // batched change detection the inner host's tag does not exist yet at ngAfterViewInit time
171
- // (the same async-commit gotcha Vue documents), so binding eagerly would no-op. whenCommitted
172
- // runs the action now if already committed, else after the commit that assigns the tag.
173
- private bindNode(
174
- leaf: AnimatedProps,
175
- props: Record<string, unknown>,
176
- wantsNative: boolean,
177
- ): void {
178
- this.cancelBind?.();
179
- this.cancelBind = undefined;
180
- const node = this.hostNode();
181
- if (node === null) return;
182
- this.cancelBind = whenCommitted(node, () => {
183
- leaf.setNativeView(node);
184
- if (wantsNative) leaf.__makeNative();
185
- this.attachEvents(node, props);
186
- });
187
- }
188
-
189
- // Native-attach any Animated.event prop (e.g. onScroll={Animated.event(…,{useNativeDriver:true})})
190
- // to the committed node. attachNativeEventHandler no-ops (returns undefined) unless the prop is a
191
- // native event handler with a committed tag, so the JS path stays the fallback. Rebound each
192
- // reconcile so a new inline event re-attaches; detached first to avoid leaking the prior binding.
193
- private attachEvents(node: ISymbioteNode, props: Record<string, unknown>): void {
194
- this.detachEvents();
195
- for (const key of Object.keys(props)) {
196
- const attachment = attachNativeEventHandler(node, key, props[key]);
197
- if (attachment !== undefined) this.eventDetachers.push(attachment.detach);
198
- }
199
- }
200
-
201
- private detachEvents(): void {
202
- for (const detach of this.eventDetachers) detach();
203
- this.eventDetachers = [];
147
+ this.leafBinder.reconcile(props, wantsNative);
204
148
  }
205
149
 
206
150
  // Merge the dedicated `style` input over the generic `animatedProps` bag into one props map —
@@ -286,9 +230,13 @@ export class AnimatedImage extends ImageBase implements AfterViewInit, OnChanges
286
230
 
287
231
  @ViewChild(SymbioteHostPropsDirective) private hostDirective?: SymbioteHostPropsDirective;
288
232
 
289
- private attached: AnimatedProps | null = null;
290
- private cancelBind: (() => void) | undefined;
291
- private eventDetachers: Array<() => void> = [];
233
+ // Same Pure Fabrication AnimatedComponentBase holds — see its matching comment. AnimatedImage
234
+ // can't extend AnimatedComponentBase (it must extend ImageBase, see the file header), so it
235
+ // gets the leaf-lifecycle via composition instead of duplicating it.
236
+ private readonly leafBinder = new AnimatedLeafBinder(
237
+ () => this.hostNode(),
238
+ this.constructor.name,
239
+ );
292
240
  private viewReady = false;
293
241
 
294
242
  get animatedImageProps(): Record<string, unknown> {
@@ -313,13 +261,7 @@ export class AnimatedImage extends ImageBase implements AfterViewInit, OnChanges
313
261
  }
314
262
 
315
263
  ngOnDestroy(): void {
316
- this.cancelBind?.();
317
- this.cancelBind = undefined;
318
- this.detachEvents();
319
- if (this.attached !== null) {
320
- this.attached.__detach();
321
- this.attached = null;
322
- }
264
+ this.leafBinder.destroy();
323
265
  }
324
266
 
325
267
  private reconcile(): void {
@@ -329,42 +271,7 @@ export class AnimatedImage extends ImageBase implements AfterViewInit, OnChanges
329
271
  this.passthroughAnimatedPropExplicitValues !== null &&
330
272
  this.passthroughAnimatedPropExplicitValues !== undefined;
331
273
  const wantsNative = hasPassthroughAnimatedValues && isNativeAnimatedAvailable();
332
-
333
- const newLeaf = new AnimatedProps(props);
334
- newLeaf.__attach();
335
- if (this.attached !== null && this.attached !== newLeaf) this.attached.__detach();
336
- this.attached = newLeaf;
337
-
338
- this.bindNode(newLeaf, props, wantsNative);
339
- }
340
-
341
- private bindNode(
342
- leaf: AnimatedProps,
343
- props: Record<string, unknown>,
344
- wantsNative: boolean,
345
- ): void {
346
- this.cancelBind?.();
347
- this.cancelBind = undefined;
348
- const node = this.hostNode();
349
- if (node === null) return;
350
- this.cancelBind = whenCommitted(node, () => {
351
- leaf.setNativeView(node);
352
- if (wantsNative) leaf.__makeNative();
353
- this.attachEvents(node, props);
354
- });
355
- }
356
-
357
- private attachEvents(node: ISymbioteNode, props: Record<string, unknown>): void {
358
- this.detachEvents();
359
- for (const key of Object.keys(props)) {
360
- const attachment = attachNativeEventHandler(node, key, props[key]);
361
- if (attachment !== undefined) this.eventDetachers.push(attachment.detach);
362
- }
363
- }
364
-
365
- private detachEvents(): void {
366
- for (const detach of this.eventDetachers) detach();
367
- this.eventDetachers = [];
274
+ this.leafBinder.reconcile(props, wantsNative);
368
275
  }
369
276
 
370
277
  private mergedProps(): Record<string, unknown> {
@@ -389,7 +296,7 @@ export class AnimatedImage extends ImageBase implements AfterViewInit, OnChanges
389
296
  inputs: ANIMATED_INPUTS,
390
297
  template: `
391
298
  <symbiote-scroll-view [symbioteHostProps]="reducedProps">
392
- <symbiote-scroll-content [collapsable]="false">
299
+ <symbiote-scroll-content [symbioteHostProps]="contentProps">
393
300
  <ng-content></ng-content>
394
301
  </symbiote-scroll-content>
395
302
  </symbiote-scroll-view>
@@ -402,11 +309,34 @@ export class AnimatedScrollView extends AnimatedComponentBase {
402
309
  // receives touch (swallowed by the outer scroll view; iOS doesn't gate on this). The real
403
310
  // ScrollView component already defaults this (scroll-view/shared.ts), but AnimatedScrollView
404
311
  // talks to the raw primitive directly and has no such defaulting, so it must apply it too.
312
+ //
313
+ // scrollViewBaseStyle (overflow: 'scroll' + the per-axis flexDirection) is the OTHER thing
314
+ // the real ScrollView applies that this bespoke template used to skip entirely — without it,
315
+ // iOS Fabric never clips the scroll view's content to its own frame (Android's native
316
+ // ViewGroup clips regardless of the style prop, which is why this was invisible there). This
317
+ // wrapper has no dedicated `horizontal` input (only the generic `animatedProps`/`style`
318
+ // surface), and its template is hardcoded to the vertical intrinsics
319
+ // (symbiote-scroll-view/symbiote-scroll-content, not the horizontal variants), so
320
+ // selectScrollIntrinsics is always called with isHorizontal=false here — matching what the
321
+ // template can actually render.
405
322
  override get reducedProps(): Record<string, unknown> {
406
323
  const reduced = super.reducedProps;
407
- return reduced.nestedScrollEnabled === undefined
408
- ? { ...reduced, nestedScrollEnabled: true }
409
- : reduced;
324
+ const { scrollViewBaseStyle } = selectScrollIntrinsics(false, undefined);
325
+ const withScrollBase: Record<string, unknown> = {
326
+ ...reduced,
327
+ style: [scrollViewBaseStyle, reduced.style],
328
+ };
329
+ return withScrollBase.nestedScrollEnabled === undefined
330
+ ? { ...withScrollBase, nestedScrollEnabled: true }
331
+ : withScrollBase;
332
+ }
333
+
334
+ // The content (inner) view's props: contentStyle from the same intrinsics selection, plus
335
+ // `collapsable: false` (the Android multi-child fix this file's tests already cover) — mirrors
336
+ // the real ScrollView's own `contentProps` getter (scroll-view/shared.ts).
337
+ get contentProps(): Record<string, unknown> {
338
+ const { contentStyle } = selectScrollIntrinsics(false, undefined);
339
+ return { style: contentStyle, collapsable: false };
410
340
  }
411
341
  }
412
342
 
@@ -9,11 +9,12 @@ import {
9
9
  clearGlobalStyles,
10
10
  createSurface,
11
11
  disposeRoot,
12
+ isAnchor,
12
13
  isSymbioteNode,
13
14
  registerStyles,
14
15
  } from '@symbiote-native/engine';
15
16
  import { installFabric } from '@symbiote-native/test-utils';
16
- import { SymbioteRenderer, SymbioteRendererFactory } from './renderer';
17
+ import { registerComposedComponent, SymbioteRenderer, SymbioteRendererFactory } from './renderer';
17
18
 
18
19
  const ROOT_TAG = 707;
19
20
  const PROBE_ID = 'probe';
@@ -176,4 +177,26 @@ describe('Angular SymbioteRenderer drives the engine', () => {
176
177
  const { renderer } = setup();
177
178
  expect(() => renderer.listen('window', 'resize', () => {})()).not.toThrow();
178
179
  });
180
+
181
+ // App/third-party selectors must not be hardcoded into ANCHOR_HOST_COMPONENTS — the owning
182
+ // package registers itself via registerComposedComponent, same as an adapter-owned composed
183
+ // component. 'RefApiDemo' names examples/angular's demo component; this test never imports
184
+ // it, it only proves the renderer treats an unregistered selector as a raw Fabric view name,
185
+ // not a hardcoded anchor host. MUST run before the registration test below, since
186
+ // registerComposedComponent mutates the module-level Set for the rest of the file.
187
+ it('does not anchor-host an app-owned selector unless it self-registers', () => {
188
+ const { renderer } = setup();
189
+ const node = renderer.createElement('RefApiDemo');
190
+ if (!isSymbioteNode(node)) throw new Error('unreachable: createElement returns a node');
191
+ expect(isAnchor(node), 'falls through to a raw Fabric view, not an anchor').toBe(false);
192
+ expect(node.component).toBe('RefApiDemo');
193
+ });
194
+
195
+ it('anchor-hosts an app-owned selector once it self-registers via registerComposedComponent', () => {
196
+ registerComposedComponent('RefApiDemo');
197
+ const { renderer } = setup();
198
+ const node = renderer.createElement('RefApiDemo');
199
+ if (!isSymbioteNode(node)) throw new Error('unreachable: createElement returns a node');
200
+ expect(isAnchor(node)).toBe(true);
201
+ });
179
202
  });
package/src/renderer.ts CHANGED
@@ -87,23 +87,15 @@ const ANCHOR_HOST_COMPONENTS: Set<string> = new Set([
87
87
  // Composed Angular components render their real Fabric descriptor tree from the template;
88
88
  // their Angular host element is only a framework bookkeeping node and must not paint. This
89
89
  // is NOT limited to adapter-authored components — ANY custom composed @Component used as a
90
- // plain <Tag> inside another template needs its selector listed here too, adapter or app
91
- // code alike, or Angular's automatic host-element creation for it falls through to a raw
92
- // Fabric createNode call with an unrecognized view name, which paints RN's own "Unimplemented
93
- // component: <Tag>" fallback view instead (a real device-visible bug, not a silent no-op
94
- // discovered when examples/angular's own new demo components hit this).
95
- 'AccessibilityDemo',
90
+ // plain <Tag> inside another template needs its selector listed here too, or Angular's
91
+ // automatic host-element creation for it falls through to a raw Fabric createNode call with
92
+ // an unrecognized view name, which paints RN's own "Unimplemented component: <Tag>" fallback
93
+ // view instead (a real device-visible bug, not a silent no-op). This Set holds only
94
+ // adapter/engine-owned selectors; app code and third-party packages self-register their own
95
+ // composed components through registerComposedComponent instead of being hardcoded here.
96
96
  'ActivityIndicator',
97
- 'AnimatedDemo',
98
- 'AnimatedParityDemo',
99
97
  'Button',
100
98
  'FlatList',
101
- 'NativeModulesDemo',
102
- 'ParityDemo',
103
- 'PlatformColorDemo',
104
- 'RefApiDemo',
105
- 'ResponderDemo',
106
- 'Slider',
107
99
  'AnimatedView',
108
100
  'symbiote-animated-view',
109
101
  'AnimatedText',