@symbiote-native/engine 0.5.0 → 1.0.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 (91) hide show
  1. package/README.md +39 -14
  2. package/android/CMakeLists.txt +51 -0
  3. package/android/build.gradle +90 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/cpp/SymbioteEngineJni.cpp +72 -0
  6. package/android/src/main/java/dev/symbiotenative/engine/SymbioteEngineModule.kt +43 -0
  7. package/android/src/main/java/dev/symbiotenative/engine/SymbioteEnginePackage.kt +35 -0
  8. package/build/accessibility-info/shared.js +1 -1
  9. package/build/accessibility-props.d.ts +1 -8
  10. package/build/accessibility-props.js +13 -16
  11. package/build/animated/animations/composition.d.ts +1 -1
  12. package/build/animated/animations/composition.js +18 -4
  13. package/build/animated/easing.d.ts +3 -2
  14. package/build/animated/easing.js +17 -88
  15. package/build/animated/event.js +6 -1
  16. package/build/animated/host-binding.d.ts +1 -1
  17. package/build/animated/host-binding.js +19 -4
  18. package/build/animated/index.d.ts +1 -1
  19. package/build/animated/mock.d.ts +1 -19
  20. package/build/animated/props.js +1 -1
  21. package/build/animated/rgba.js +16 -50
  22. package/build/events/index.js +88 -40
  23. package/build/fabric-props.d.ts +1 -1
  24. package/build/fabric-props.js +116 -184
  25. package/build/fabric.d.ts +9 -0
  26. package/build/fabric.js +32 -0
  27. package/build/host-access.d.ts +125 -0
  28. package/build/host-access.js +280 -0
  29. package/build/host-behavior.d.ts +84 -21
  30. package/build/host-behavior.js +196 -30
  31. package/build/image-source-write.d.ts +16 -0
  32. package/build/image-source-write.js +65 -0
  33. package/build/imperative.d.ts +49 -0
  34. package/build/imperative.js +258 -0
  35. package/build/index.d.ts +14 -7
  36. package/build/index.js +53 -10
  37. package/build/mutation-buffer.d.ts +222 -0
  38. package/build/mutation-buffer.js +491 -0
  39. package/build/native-engine.d.ts +182 -0
  40. package/build/native-engine.js +178 -0
  41. package/build/native-tree-host.d.ts +25 -0
  42. package/build/native-tree-host.js +66 -0
  43. package/build/node.d.ts +172 -57
  44. package/build/node.js +839 -383
  45. package/build/pan-responder/index.js +27 -52
  46. package/build/platform-color/index.d.ts +1 -1
  47. package/build/platform-color/index.js +11 -4
  48. package/build/process-background-image/index.js +30 -566
  49. package/build/process-background-longhands.d.ts +4 -0
  50. package/build/process-background-longhands.js +44 -0
  51. package/build/process-box-shadow/index.js +23 -187
  52. package/build/process-filter.js +27 -300
  53. package/build/process-transform/index.d.ts +1 -1
  54. package/build/process-transform/index.js +25 -107
  55. package/build/process-transform-origin/index.d.ts +1 -1
  56. package/build/process-transform-origin/index.js +29 -102
  57. package/build/registry.d.ts +36 -0
  58. package/build/registry.js +73 -0
  59. package/build/sound-manager/index.d.ts +3 -0
  60. package/build/sound-manager/index.js +36 -0
  61. package/build/structured-style.d.ts +10 -0
  62. package/build/structured-style.js +180 -0
  63. package/build/style-registry/index.d.ts +14 -0
  64. package/build/style-registry/index.js +60 -11
  65. package/build/surface.d.ts +31 -2
  66. package/build/surface.js +138 -56
  67. package/build/text-input-state.d.ts +1 -0
  68. package/build/text-input-state.js +17 -3
  69. package/build/tree-host.d.ts +307 -0
  70. package/build/tree-host.js +211 -0
  71. package/build/view-config.js +4 -4
  72. package/codegen-specs/NativeSymbioteEngine.ts +27 -0
  73. package/cpp/SymbioteDebug.cpp +51 -0
  74. package/cpp/SymbioteDebug.h +54 -0
  75. package/cpp/SymbioteEngineBindings.cpp +232 -0
  76. package/cpp/SymbioteEngineBindings.h +59 -0
  77. package/cpp/SymbioteFabricProps.cpp +2619 -0
  78. package/cpp/SymbioteFabricProps.h +223 -0
  79. package/cpp/SymbioteTree.cpp +2478 -0
  80. package/cpp/SymbioteTree.h +257 -0
  81. package/ios/SymbioteEngineModule.h +25 -0
  82. package/ios/SymbioteEngineModule.mm +44 -0
  83. package/package.json +31 -3
  84. package/react-native.config.cjs +23 -0
  85. package/symbiote-engine.podspec +42 -0
  86. package/build/animated/bezier.d.ts +0 -1
  87. package/build/animated/bezier.js +0 -102
  88. package/build/commit.d.ts +0 -49
  89. package/build/commit.js +0 -1058
  90. package/build/tags.d.ts +0 -2
  91. package/build/tags.js +0 -40
@@ -0,0 +1,180 @@
1
+ // The ten style keys RN parses in JS before native, and the one place they are resolved.
2
+ //
3
+ // WHY THEY RUN AT WRITE TIME AND NOT AT PAYLOAD-BUILD TIME. On a device the payload is built by
4
+ // `core/engine/cpp/SymbioteFabricProps.cpp`, which does not carry these — they are pure JS. So a
5
+ // `boxShadow: '0 2px 4px #000'` resolved only inside `fabric-props.ts` is resolved only headless,
6
+ // and the device gets the raw CSS string. Fabric's C++ parses a style string ONLY under
7
+ // `enableNativeCSSParsing()`, which defaults to FALSE, so the declaration is dropped in silence:
8
+ // no warning, no wrong value, just a gradient or a shadow that is not there. Resolving on the way
9
+ // IN puts the structured value in `node.props` itself, which is the one thing both payload builders
10
+ // read.
11
+ //
12
+ // This is the same move `configPayloadFold` makes for a third-party view's own processors, one
13
+ // layer down: anything the C++ half cannot do has to happen before the C++ half sees the value.
14
+ //
15
+ // IDENTITY IS PART OF THE CONTRACT. A style object that needs nothing comes back BY IDENTITY, and
16
+ // so does an array whose every entry did. The host's `OP_SET_PROP` skips a same-identity write, and
17
+ // `pushClassStyle` compares what it is about to publish against what it published last — hand
18
+ // either of them a fresh object per write and an unchanged style becomes a write and a dirty node
19
+ // on every render.
20
+ import { processAspectRatio } from './process-aspect-ratio.js';
21
+ import { processBackgroundImage } from './process-background-image/index.js';
22
+ import { processBackgroundPosition, processBackgroundRepeat, processBackgroundSize, } from './process-background-longhands.js';
23
+ import { processBoxShadow } from './process-box-shadow/index.js';
24
+ import { processFilter } from './process-filter.js';
25
+ import { processFontVariant } from './process-font-variant.js';
26
+ import { processTransform } from './process-transform/index.js';
27
+ import { processTransformOrigin } from './process-transform-origin/index.js';
28
+ import { isRecord, isString } from './type-guards.js';
29
+ function isStringOrNumber(value) {
30
+ return typeof value === 'string' || typeof value === 'number';
31
+ }
32
+ // boxShadow accepts a CSS string or an array of shadow objects; anything else is
33
+ // undefined to processBoxShadow (which returns []). Narrowing avoids an `as` cast.
34
+ function asBoxShadowInput(value) {
35
+ if (typeof value === 'string')
36
+ return value;
37
+ if (Array.isArray(value))
38
+ return value.filter(isRecord);
39
+ return undefined;
40
+ }
41
+ // filter accepts a CSS string or an array of single-key filter objects; same narrowing.
42
+ function asFilterInput(value) {
43
+ if (typeof value === 'string')
44
+ return value;
45
+ if (Array.isArray(value))
46
+ return value.filter(isRecord);
47
+ return undefined;
48
+ }
49
+ // experimental_backgroundImage accepts a CSS string (gradient functions) or an array of
50
+ // structured gradient objects; same narrowing as boxShadow/filter.
51
+ function asBackgroundImageInput(value) {
52
+ if (typeof value === 'string')
53
+ return value;
54
+ if (Array.isArray(value))
55
+ return value.filter(isRecord);
56
+ return undefined;
57
+ }
58
+ // transformOrigin accepts a CSS string or a [x, y, z] array of strings/numbers; anything
59
+ // else is undefined to processTransformOrigin (which defaults to center/center/0).
60
+ function asTransformOriginInput(value) {
61
+ if (typeof value === 'string')
62
+ return value;
63
+ if (Array.isArray(value))
64
+ return value.filter(isStringOrNumber);
65
+ return undefined;
66
+ }
67
+ // aspectRatio accepts a number (the common, working form) or a ratio string; otherwise
68
+ // undefined, which processAspectRatio drops.
69
+ function asAspectRatioInput(value) {
70
+ if (typeof value === 'number' || typeof value === 'string')
71
+ return value;
72
+ return undefined;
73
+ }
74
+ // fontVariant accepts an array of variant strings (the common, working form) or a
75
+ // space-separated string; anything else becomes an empty string, which yields [].
76
+ function asFontVariantInput(value) {
77
+ if (typeof value === 'string')
78
+ return value;
79
+ if (Array.isArray(value))
80
+ return value.filter(isString);
81
+ return '';
82
+ }
83
+ // transform accepts a CSS string (processTransform parses it) or an array of single-key
84
+ // transform records (the hot animated / sticky-header path). A non-string non-array value is NOT
85
+ // dropped: it may already be processed, so it passes through verbatim rather than being coerced to
86
+ // [] (which would erase a valid transform).
87
+ function processTransformValue(value) {
88
+ if (typeof value === 'string')
89
+ return processTransform(value);
90
+ if (Array.isArray(value))
91
+ return processTransform(value.filter(isRecord));
92
+ return value;
93
+ }
94
+ const STYLE_PROCESSORS = new Map([
95
+ ['boxShadow', value => processBoxShadow(asBoxShadowInput(value))],
96
+ ['filter', value => processFilter(asFilterInput(value))],
97
+ [
98
+ 'transformOrigin',
99
+ value => processTransformOrigin(asTransformOriginInput(value)),
100
+ ],
101
+ ['transform', processTransformValue],
102
+ ['aspectRatio', value => processAspectRatio(asAspectRatioInput(value))],
103
+ ['fontVariant', value => processFontVariant(asFontVariantInput(value))],
104
+ [
105
+ 'experimental_backgroundImage',
106
+ value => processBackgroundImage(asBackgroundImageInput(value)),
107
+ ],
108
+ [
109
+ 'experimental_backgroundSize',
110
+ value => processBackgroundSize(asBackgroundLonghandInput(value)),
111
+ ],
112
+ [
113
+ 'experimental_backgroundPosition',
114
+ value => processBackgroundPosition(asBackgroundLonghandInput(value)),
115
+ ],
116
+ [
117
+ 'experimental_backgroundRepeat',
118
+ value => processBackgroundRepeat(asBackgroundLonghandInput(value)),
119
+ ],
120
+ ]);
121
+ // All three longhands take the same two shapes: a CSS string, or an already-structured array RN
122
+ // passes through untouched. Anything else is undefined, which upstream answers with [] and the
123
+ // wrapper turns into an absent key.
124
+ function asBackgroundLonghandInput(value) {
125
+ if (typeof value === 'string')
126
+ return value;
127
+ if (Array.isArray(value))
128
+ return value;
129
+ return undefined;
130
+ }
131
+ // Keyed on the style object, so a class-resolved style shared by a thousand rows is resolved once.
132
+ // The value is the object to USE — which is the input itself whenever nothing needed resolving, so
133
+ // a hit costs one lookup and returns the same reference the caller already had.
134
+ const resolvedStyles = new WeakMap();
135
+ function resolveRecord(style) {
136
+ const cached = resolvedStyles.get(style);
137
+ if (cached !== undefined)
138
+ return cached;
139
+ let out;
140
+ for (const [key, process] of STYLE_PROCESSORS) {
141
+ if (!(key in style))
142
+ continue;
143
+ const value = style[key];
144
+ if (value === undefined)
145
+ continue;
146
+ const next = process(value);
147
+ if (Object.is(next, value))
148
+ continue;
149
+ const claimed = out ?? { ...style };
150
+ claimed[key] = next;
151
+ out = claimed;
152
+ }
153
+ const answer = out ?? style;
154
+ resolvedStyles.set(style, answer);
155
+ return answer;
156
+ }
157
+ /**
158
+ * A style value with its structured keys resolved — the same value by identity when there was
159
+ * nothing to resolve, which is nearly always.
160
+ *
161
+ * Accepts what a style slot can hold: one object, or a (nested) array of them. An array is not
162
+ * memoized — `pushClassStyle` mints a fresh one on every publish, so a cache keyed on it could
163
+ * never hit; its ENTRIES carry the memo instead, and the original array comes back untouched when
164
+ * none of them moved.
165
+ */
166
+ export function resolveStructuredStyle(style) {
167
+ if (Array.isArray(style)) {
168
+ let out;
169
+ for (let at = 0; at < style.length; at += 1) {
170
+ const next = resolveStructuredStyle(style[at]);
171
+ if (Object.is(next, style[at]))
172
+ continue;
173
+ const claimed = out ?? [...style];
174
+ claimed[at] = next;
175
+ out = claimed;
176
+ }
177
+ return out ?? style;
178
+ }
179
+ return isRecord(style) ? resolveRecord(style) : style;
180
+ }
@@ -13,6 +13,20 @@ export declare function registerRules(rules: readonly IStyleRule[]): void;
13
13
  export declare function clearGlobalStyles(): void;
14
14
  export declare function isClassNameValue(value: unknown): value is IClassNameValue;
15
15
  export declare function canonicalClassName(className: IClassNameValue): IClassNameValue;
16
+ /**
17
+ * The one object every "this class styles nothing" answer hands back.
18
+ *
19
+ * A FRESH `{}` was the previous answer, and it quietly cost two things. `isAlreadyPublished`
20
+ * compares slot 0 with `Object.is`, so a node whose class is absent or matches no rule republished
21
+ * its whole style — and re-dirtied itself — on every class or style write, which is precisely the
22
+ * storm that guard exists to stop; and `pushClassStyle` had no way to tell "resolved to nothing"
23
+ * from "resolved to something", so the empty result crossed the wire as a real value.
24
+ *
25
+ * Frozen because it is now shared by every such node in the app: a caller that mutated the result
26
+ * used to corrupt one node's style and would now corrupt all of them. No caller does — every one
27
+ * spreads it — and freezing is what keeps that true.
28
+ */
29
+ export declare const EMPTY_STYLE: IResolvedStyle;
16
30
  export declare function resolveClassName(className: IClassNameValue): IResolvedStyle;
17
31
  export declare function resolveActiveClassName(className: IClassNameValue): IResolvedStyle;
18
32
  export {};
@@ -58,8 +58,9 @@ let ruleEpoch = 0;
58
58
  // flattenStyle shallow-copies.
59
59
  const resolvedCache = new Map();
60
60
  // Bounded so a screen generating unique class strings at runtime cannot grow it without limit.
61
- // Overflow drops everything rather than evicting one entry: the cache is a warm-up optimization,
62
- // not a working set, and an LRU's bookkeeping costs more than the rebuild it saves.
61
+ // Overflow evicts ONE entry at random — see `memoise` for why random and not the obvious
62
+ // alternatives. It used to drop everything, which turned a working set one entry too wide into a
63
+ // total miss on every lookup.
63
64
  const RESOLVED_CACHE_LIMIT = 512;
64
65
  // The pressed variant, keyed by the SAME authored string. Separate from `resolvedCache` so the two
65
66
  // can never collide on one key, and string-keyed for the same reason that one is: identity is the
@@ -87,7 +88,45 @@ let hasActiveRules = false;
87
88
  // per commit, and only the second one is hot.
88
89
  function invalidateResolved() {
89
90
  resolvedCache.clear();
91
+ resolvedKeys.length = 0;
90
92
  activeCache.clear();
93
+ activeKeys.length = 0;
94
+ }
95
+ // The victim pool for the two caches below — a plain array of the keys each holds, so a random
96
+ // eviction is an index rather than a scan. This is the entire bookkeeping cost of the policy, and
97
+ // it is less than FIFO's would be: nothing about recency or order is tracked.
98
+ const resolvedKeys = [];
99
+ const activeKeys = [];
100
+ /**
101
+ * Memoise, evicting a RANDOM entry when full.
102
+ *
103
+ * WHY RANDOM, since it looks arbitrary next to the obvious answers. The policy this replaced dropped
104
+ * the whole cache on overflow, and the reflex repair — evict the oldest — is no better on the access
105
+ * pattern that actually hurts. A working set slightly WIDER than the cache, walked in order, is
106
+ * Belady's worst case: FIFO and LRU each evict exactly the entry the next lookup wants, so both miss
107
+ * on every single access, exactly as the full clear did. Measured on a 512-entry cache cycling 513
108
+ * distinct class strings: clear 0%, FIFO 0%, random 94.7% (`cache-cliff.probe.test.ts`).
109
+ *
110
+ * Random also answers the objection that ruled LRU out here — it needs LESS bookkeeping, not more.
111
+ * There is no recency to maintain; a key array and a swap-remove is the whole of it.
112
+ *
113
+ * The cache stays a pure memo of a pure function either way, so which entries survive changes only
114
+ * which lookups pay the rebuild. `invalidateResolved` still drops everything when the cascade moves,
115
+ * because then nothing resolved earlier is true any more.
116
+ */
117
+ function memoise(cache, keys, key, value) {
118
+ if (cache.size >= RESOLVED_CACHE_LIMIT) {
119
+ const victimAt = Math.floor(Math.random() * keys.length);
120
+ const victim = keys[victimAt];
121
+ if (victim !== undefined) {
122
+ cache.delete(victim);
123
+ const last = keys.pop();
124
+ if (last !== undefined && victimAt < keys.length)
125
+ keys[victimAt] = last;
126
+ }
127
+ }
128
+ cache.set(key, value);
129
+ keys.push(key);
91
130
  }
92
131
  export function registerRules(rules) {
93
132
  invalidateResolved();
@@ -142,9 +181,23 @@ export function canonicalClassName(className) {
142
181
  ? className.join(' ')
143
182
  : className;
144
183
  }
184
+ /**
185
+ * The one object every "this class styles nothing" answer hands back.
186
+ *
187
+ * A FRESH `{}` was the previous answer, and it quietly cost two things. `isAlreadyPublished`
188
+ * compares slot 0 with `Object.is`, so a node whose class is absent or matches no rule republished
189
+ * its whole style — and re-dirtied itself — on every class or style write, which is precisely the
190
+ * storm that guard exists to stop; and `pushClassStyle` had no way to tell "resolved to nothing"
191
+ * from "resolved to something", so the empty result crossed the wire as a real value.
192
+ *
193
+ * Frozen because it is now shared by every such node in the app: a caller that mutated the result
194
+ * used to corrupt one node's style and would now corrupt all of them. No caller does — every one
195
+ * spreads it — and freezing is what keeps that true.
196
+ */
197
+ export const EMPTY_STYLE = Object.freeze({});
145
198
  export function resolveClassName(className) {
146
199
  if (!className)
147
- return {};
200
+ return EMPTY_STYLE;
148
201
  if (typeof className === 'object' && !Array.isArray(className)) {
149
202
  return className;
150
203
  }
@@ -162,9 +215,7 @@ export function resolveClassName(className) {
162
215
  if (cached !== undefined)
163
216
  return cached;
164
217
  const resolved = resolveClassString(className);
165
- if (resolvedCache.size >= RESOLVED_CACHE_LIMIT)
166
- resolvedCache.clear();
167
- resolvedCache.set(className, resolved);
218
+ memoise(resolvedCache, resolvedKeys, className, resolved);
168
219
  return resolved;
169
220
  }
170
221
  // The same element's style with `:active` added to its token list — so a `.btn:active` rule joins
@@ -191,16 +242,14 @@ export function resolveActiveClassName(className) {
191
242
  return cached;
192
243
  const parts = className.trim().split(/\s+/).filter(Boolean);
193
244
  const resolved = parts.length === 0 ? {} : (matchRules([...parts, STATE_TOKEN]) ?? {});
194
- if (activeCache.size >= RESOLVED_CACHE_LIMIT)
195
- activeCache.clear();
196
- activeCache.set(className, resolved);
245
+ memoise(activeCache, activeKeys, className, resolved);
197
246
  return resolved;
198
247
  }
199
248
  function resolveClassString(className) {
200
249
  const parts = className.trim().split(/\s+/).filter(Boolean);
201
250
  if (parts.length === 0)
202
- return {};
203
- return matchRules(parts) ?? {};
251
+ return EMPTY_STYLE;
252
+ return matchRules(parts) ?? EMPTY_STYLE;
204
253
  }
205
254
  // `null` rather than `{}` for "nothing matched", so the caller can skip the empty-object churn on
206
255
  // every class-prop set that has no rules.
@@ -2,15 +2,44 @@ import type { IRootTag } from './fabric';
2
2
  import { type ISymbioteNode } from './node';
3
3
  export declare class SymbioteSurface {
4
4
  readonly rootTag: IRootTag;
5
- readonly children: ISymbioteNode[];
5
+ /** The AppContainer root every top-level node hangs off, and the handle `OP_COMMIT` names. */
6
+ private readonly node;
6
7
  private commitScheduled;
7
8
  constructor(rootTag: IRootTag);
9
+ /**
10
+ * Every OTHER live surface, so one commit names every root — see `commitSurfaceOps`.
11
+ *
12
+ * Static because it reads a sibling instance's private handle, which only a member of this class
13
+ * may do. One surface — the universal case — allocates nothing.
14
+ *
15
+ * `self` may already be OUT of the registry: a teardown unregisters and then commits, and that
16
+ * commit is the one carrying the removals. So the fast path cannot be a size check — with one
17
+ * live surface left, `size === 1` means either "only me" or "only the other one", and taking the
18
+ * shortcut on the second reading is how a surface loses the very batch that empties it.
19
+ */
20
+ private static others;
21
+ /**
22
+ * The top-level nodes, asked of the host.
23
+ *
24
+ * A getter rather than an array this class maintains: a second copy of a child list is the thing
25
+ * this design removes, and `nextSiblingOf` (host-access.ts) needs the same answer the host gives
26
+ * for a parented node.
27
+ */
28
+ get children(): readonly ISymbioteNode[];
8
29
  appendChild(child: ISymbioteNode): void;
9
30
  insertBefore(child: ISymbioteNode, beforeChild: ISymbioteNode): void;
10
31
  removeChild(child: ISymbioteNode): void;
11
32
  clear(): void;
33
+ /**
34
+ * Release every host behavior still standing under this surface, at unmount.
35
+ *
36
+ * The sweep above cannot answer this: it only sees nodes a `removeChild` NOMINATED, and an
37
+ * unmount removes nothing — the adapter drops the whole surface. Without it every node keeps its
38
+ * `afterCommit` registration and its timers, and a restarted surface's commits drain the dead
39
+ * one's hooks forever.
40
+ */
41
+ teardown(): void;
12
42
  commit(): void;
13
43
  requestCommit(): void;
14
- private detach;
15
44
  }
16
45
  export declare function createSurface(rootTag: IRootTag): SymbioteSurface;
package/build/surface.js CHANGED
@@ -1,57 +1,147 @@
1
- // A surface is one mounted root: it owns the rootTag handed down by the native
2
- // Fabric host and the list of top-level retained nodes. Adapters mutate it and
3
- // ask it to commit; the surface coalesces commits and drives the engine.
4
- import { commitChildren } from './commit.js';
1
+ // A surface is one mounted root: it owns the rootTag handed down by the native Fabric host and the
2
+ // top-level nodes under it. Adapters mutate it and ask it to commit.
3
+ //
4
+ // A SURFACE IS ONE ORDINARY NODE, and that is the whole implementation rather than a trick. It holds
5
+ // a real handle, its child ops are the ordinary append / insertBefore / removeChild, and `OP_COMMIT`
6
+ // names that one handle — so the host needs no `rootTag -> node` map, which is what keeps it
7
+ // stateless.
8
+ //
9
+ // The node is RN's AppContainer (`createSurfaceRoot` in `node.ts` — `flex: 1`, `box-none`), so what
10
+ // reaches the root child set is one view with the app under it. An ANCHOR in the same position
11
+ // hoists its children instead, and the host takes both through the same call, so nothing here is a
12
+ // special case.
5
13
  import { dlog } from './debug.js';
6
14
  import { installEventHandler } from './events/index.js';
7
- import { markStructureDirty } from './node.js';
8
- import { hasHostBehaviors, markDetachCandidate } from './host-behavior.js';
9
- import { hasAnimatedBindings } from './animated/host-binding.js';
15
+ import { detachAnimatedProps } from './animated/host-binding.js';
16
+ import { childrenOf } from './host-access.js';
17
+ import { runCommittedHooks, runDeferredAttaches, hasDetachCandidates, sweepDetachedBehaviors, teardownSubtree, } from './host-behavior.js';
18
+ import { getNativeTag, notifyCommitted, registerSurfaceCommit, } from './imperative.js';
19
+ import { runPostCommitHooks } from './post-commit.js';
20
+ import { appendChild, createSurfaceRoot, insertBefore, removeChild, } from './node.js';
21
+ import { commitSurfaceOps, flushOps } from './tree-host.js';
22
+ const NO_CO_COMMITTERS = [];
23
+ // The predicate both behavior drains take. Passed in rather than imported by `host-behavior.ts`,
24
+ // keeping that dependency one-directional — a cycle there is a live hazard under Metro's
25
+ // `inlineRequires`.
26
+ const isNodeCommitted = (node) => getNativeTag(node) !== undefined;
10
27
  export class SymbioteSurface {
11
28
  rootTag;
12
- children = [];
29
+ /** The AppContainer root every top-level node hangs off, and the handle `OP_COMMIT` names. */
30
+ node;
13
31
  commitScheduled = false;
14
32
  constructor(rootTag) {
15
33
  this.rootTag = rootTag;
34
+ this.node = createSurfaceRoot();
35
+ }
36
+ /**
37
+ * Every OTHER live surface, so one commit names every root — see `commitSurfaceOps`.
38
+ *
39
+ * Static because it reads a sibling instance's private handle, which only a member of this class
40
+ * may do. One surface — the universal case — allocates nothing.
41
+ *
42
+ * `self` may already be OUT of the registry: a teardown unregisters and then commits, and that
43
+ * commit is the one carrying the removals. So the fast path cannot be a size check — with one
44
+ * live surface left, `size === 1` means either "only me" or "only the other one", and taking the
45
+ * shortcut on the second reading is how a surface loses the very batch that empties it.
46
+ */
47
+ static others(self) {
48
+ let out;
49
+ for (const other of surfaces.values()) {
50
+ if (other === self)
51
+ continue;
52
+ out ??= [];
53
+ out.push([other.rootTag, other.node]);
54
+ }
55
+ return out ?? NO_CO_COMMITTERS;
56
+ }
57
+ /**
58
+ * The top-level nodes, asked of the host.
59
+ *
60
+ * A getter rather than an array this class maintains: a second copy of a child list is the thing
61
+ * this design removes, and `nextSiblingOf` (host-access.ts) needs the same answer the host gives
62
+ * for a parented node.
63
+ */
64
+ get children() {
65
+ return childrenOf(this.node);
16
66
  }
17
67
  appendChild(child) {
18
- this.detach(child);
19
- child.parent = undefined;
20
- this.children.push(child);
68
+ appendChild(this.node, child);
21
69
  }
22
70
  insertBefore(child, beforeChild) {
23
- this.detach(child);
24
- child.parent = undefined;
25
- const index = this.children.indexOf(beforeChild);
26
- this.children.splice(index < 0 ? this.children.length : index, 0, child);
71
+ insertBefore(this.node, child, beforeChild);
27
72
  }
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.
73
+ // Nomination for teardown rides on `node.ts`'s `removeChild`, and only NOMINATES for the reason
74
+ // stated there: a framework may spell a move as remove-then-reinsert, so the commit sweep
75
+ // decides. The surface is one ordinary node, so it is not a second removal path that could miss
76
+ // the sweep and leave a behavior — its timers included — outliving the surface.
35
77
  removeChild(child) {
36
- const index = this.children.indexOf(child);
37
- if (index >= 0)
38
- this.children.splice(index, 1);
39
- if (hasHostBehaviors() || hasAnimatedBindings())
40
- markDetachCandidate(child);
78
+ removeChild(this.node, child);
41
79
  }
42
80
  clear() {
43
- if (hasHostBehaviors() || hasAnimatedBindings())
44
- for (const child of this.children)
45
- markDetachCandidate(child);
46
- this.children.length = 0;
81
+ for (const child of this.children)
82
+ removeChild(this.node, child);
83
+ }
84
+ /**
85
+ * Release every host behavior still standing under this surface, at unmount.
86
+ *
87
+ * The sweep above cannot answer this: it only sees nodes a `removeChild` NOMINATED, and an
88
+ * unmount removes nothing — the adapter drops the whole surface. Without it every node keeps its
89
+ * `afterCommit` registration and its timers, and a restarted surface's commits drain the dead
90
+ * one's hooks forever.
91
+ */
92
+ teardown() {
93
+ // The nominations first: an adapter that empties the surface and disposes it without a commit
94
+ // in between (React's `clearContainer`) never reaches the commit sweep, and the walk below
95
+ // cannot see those nodes either — they already left the tree.
96
+ sweepDetachedBehaviors(this.children, detachAnimatedProps);
97
+ teardownSubtree(this.node, detachAnimatedProps);
47
98
  }
48
- // Synchronous commit: used by React's resetAfterCommit, which already
49
- // batches per logical update.
99
+ // Synchronous commit: used by React's resetAfterCommit, which already batches per logical update.
50
100
  commit() {
51
- commitChildren(this.rootTag, this.children);
101
+ // A SUPERSEDED surface still flushes its ops and still names every other root — its teardown is
102
+ // what carries the removals — but it must not complete a root another surface now owns. Fast
103
+ // Refresh and the focus lifecycle re-mount the same rootTag, so the old surface's teardown
104
+ // commit lands AFTER the new one's mount commit and would hand Fabric the emptied tree.
105
+ // An UNREGISTERED surface is not superseded — nobody took the root, so its final emptied tree
106
+ // is still the truth for it. Only a live OTHER owner suppresses the op.
107
+ // The teardown half of the behavior lifecycle. It runs AFTER the ops are applied — it decides
108
+ // what really left by asking the host for a parent, and the host does not know about a removal
109
+ // it has not been handed — but BEFORE the root is completed, so a behavior's parting writes
110
+ // (ScrollView taking its forced `scrollEventThrottle` back) ride this commit instead of owing
111
+ // another one. `flushOps` is that split: apply, do not publish.
112
+ //
113
+ // `removeChild` only NOMINATES — a framework spells a move as remove-then-reinsert, so tearing
114
+ // down at the call would kill a machine that comes back in the same batch.
115
+ flushOps();
116
+ // GUARDED AT THE CALL SITE, not inside the sweep — `this.children` is a host read that builds
117
+ // the whole top-level list, and it would run on every commit for a sweep that had nothing to do.
118
+ if (hasDetachCandidates()) {
119
+ sweepDetachedBehaviors(this.children, detachAnimatedProps);
120
+ }
121
+ const owner = surfaces.get(this.rootTag);
122
+ const superseded = owner !== undefined && owner !== this;
123
+ commitSurfaceOps(superseded ? undefined : this.rootTag, this.node, SymbioteSurface.others(this));
124
+ // Fresh Fabric handles are now assigned, so the three things that could not run before one
125
+ // existed all drain here — this is the moment the old `commitChildren` drained them too.
126
+ //
127
+ // `notifyCommitted` releases the imperative waiters (`whenCommitted`); `runPostCommitHooks` the
128
+ // consumers that needed a committed TAG and ran too early, which is the Animated native driver
129
+ // binding a props node under an async-batched commit; `runDeferredAttaches` the half of a host
130
+ // behavior whose setup needs a tag (a view command, an event attach). The predicate is passed in
131
+ // rather than imported by `host-behavior.ts`, keeping that dependency one-directional — a cycle
132
+ // there is a live hazard under Metro's `inlineRequires`.
133
+ notifyCommitted();
134
+ runPostCommitHooks();
135
+ runDeferredAttaches(isNodeCommitted);
136
+ // After the setup half, never before it: a node carrying both hooks has `attachAfterCommit`
137
+ // seed the mirrors `afterCommit` then compares against. Unlike the two above it asks only
138
+ // "props were published", so it is NOT gated on the commit having made native calls — a fold
139
+ // that strips a prop makes its own commit byte-identical, and the hook that must react to the
140
+ // flip would be the one the flip cannot wake.
141
+ runCommittedHooks(isNodeCommitted);
52
142
  }
53
- // Coalesced commit: for reactive frameworks that emit many mutations per
54
- // tick. Collapses to a single completeRoot at the microtask boundary.
143
+ // Coalesced commit: for reactive frameworks that emit many mutations per tick. Collapses to a
144
+ // single completeRoot at the microtask boundary.
55
145
  requestCommit() {
56
146
  if (this.commitScheduled)
57
147
  return;
@@ -61,30 +151,22 @@ export class SymbioteSurface {
61
151
  this.commit();
62
152
  });
63
153
  }
64
- // Splices `parent.children` directly instead of going through node.ts's removeChild, so it
65
- // owes the same marks - otherwise a node pulled out of a subtree here leaves that subtree
66
- // looking clean and the commit walk skips right over the hole, and commitTargeted would rebuild
67
- // that parent's child set from a snapshot that still contains the removed node.
68
- detach(child) {
69
- const parent = child.parent;
70
- if (parent) {
71
- // Marks before the splice, like node.ts's own structural ops: the committed record may be
72
- // aliasing `parent.children`, and this call is what copies it out of the way.
73
- markStructureDirty(parent);
74
- const index = parent.children.indexOf(child);
75
- if (index >= 0)
76
- parent.children.splice(index, 1);
77
- child.parent = undefined;
78
- return;
79
- }
80
- const topIndex = this.children.indexOf(child);
81
- if (topIndex >= 0)
82
- this.children.splice(topIndex, 1);
83
- }
84
154
  }
155
+ // Every live surface, so the microtask flush in `imperative.ts` can commit the one a queued write
156
+ // named. Registered from here rather than imported there: a surface owns its own commit, and
157
+ // reaching into it from the imperative half would put back the cycle that split exists to remove.
158
+ const surfaces = new Map();
159
+ registerSurfaceCommit(rootTag => {
160
+ surfaces.get(rootTag)?.commit();
161
+ }, rootTag => {
162
+ const surface = surfaces.get(rootTag);
163
+ surfaces.delete(rootTag);
164
+ surface?.teardown();
165
+ });
85
166
  export function createSurface(rootTag) {
86
167
  installEventHandler();
87
168
  const surface = new SymbioteSurface(rootTag);
169
+ surfaces.set(rootTag, surface);
88
170
  dlog(`surface created root=${rootTag}`);
89
171
  return surface;
90
172
  }
@@ -3,3 +3,4 @@ export declare function currentlyFocusedInput(): ISymbioteNode | null;
3
3
  export declare function setInputFocused(node: ISymbioteNode): void;
4
4
  export declare function setInputBlurred(node: ISymbioteNode): void;
5
5
  export declare function blurTextInput(node: ISymbioteNode | null): void;
6
+ export declare function focusTextInput(node: ISymbioteNode | null): void;
@@ -2,7 +2,7 @@
2
2
  // JS-side because native exposes no focus getter. TextInput reports focus/blur here so
3
3
  // Keyboard.dismiss can blur whatever holds focus without a ref, exactly how RN's
4
4
  // dismissKeyboard() works (blurTextInput(currentlyFocusedInput())).
5
- import { dispatchViewCommand } from './commit.js';
5
+ import { dispatchViewCommand, propOf } from './imperative.js';
6
6
  import { dlog } from './debug.js';
7
7
  let currentlyFocused = null;
8
8
  // The input that last reported focus and hasn't reported blur, or null.
@@ -19,11 +19,25 @@ export function setInputBlurred(node) {
19
19
  currentlyFocused = null;
20
20
  }
21
21
  // Imperative blur: drive the native `blur` view command and drop the tracked focus.
22
- // Used by TextInput.blur() and Keyboard.dismiss().
22
+ // Used by TextInput.blur() and Keyboard.dismiss(). A no-op if this node isn't the
23
+ // currently-focused one — mirrors RN's TextInputState.blurTextInput, which guards the
24
+ // same way so blurring an already-unfocused input never reaches native.
23
25
  export function blurTextInput(node) {
24
- if (node === null)
26
+ if (node === null || currentlyFocused !== node)
25
27
  return;
26
28
  dlog('TextInputState.blurTextInput -> blur command');
27
29
  dispatchViewCommand(node, 'blur', []);
28
30
  setInputBlurred(node);
29
31
  }
32
+ // Imperative focus: RN's `ReactNativeElement.focus()` routes a text input through
33
+ // `TextInputState.focusTextInput`, not a raw command — same guard as blur, plus a check
34
+ // this side of the pair also carries: already-focused or `editable: false` is a no-op.
35
+ export function focusTextInput(node) {
36
+ if (node === null)
37
+ return;
38
+ if (currentlyFocused === node || propOf(node, 'editable') === false)
39
+ return;
40
+ dlog('TextInputState.focusTextInput -> focus command');
41
+ setInputFocused(node);
42
+ dispatchViewCommand(node, 'focus', []);
43
+ }