@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.
- package/README.md +39 -14
- package/android/CMakeLists.txt +51 -0
- package/android/build.gradle +90 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/cpp/SymbioteEngineJni.cpp +72 -0
- package/android/src/main/java/dev/symbiotenative/engine/SymbioteEngineModule.kt +43 -0
- package/android/src/main/java/dev/symbiotenative/engine/SymbioteEnginePackage.kt +35 -0
- package/build/accessibility-info/shared.js +1 -1
- package/build/accessibility-props.d.ts +1 -8
- package/build/accessibility-props.js +13 -16
- package/build/animated/animations/composition.d.ts +1 -1
- package/build/animated/animations/composition.js +18 -4
- package/build/animated/easing.d.ts +3 -2
- package/build/animated/easing.js +17 -88
- package/build/animated/event.js +6 -1
- package/build/animated/host-binding.d.ts +1 -1
- package/build/animated/host-binding.js +19 -4
- package/build/animated/index.d.ts +1 -1
- package/build/animated/mock.d.ts +1 -19
- package/build/animated/props.js +1 -1
- package/build/animated/rgba.js +16 -50
- package/build/events/index.js +88 -40
- package/build/fabric-props.d.ts +1 -1
- package/build/fabric-props.js +116 -184
- package/build/fabric.d.ts +9 -0
- package/build/fabric.js +32 -0
- package/build/host-access.d.ts +125 -0
- package/build/host-access.js +280 -0
- package/build/host-behavior.d.ts +84 -21
- package/build/host-behavior.js +196 -30
- package/build/image-source-write.d.ts +16 -0
- package/build/image-source-write.js +65 -0
- package/build/imperative.d.ts +49 -0
- package/build/imperative.js +258 -0
- package/build/index.d.ts +14 -7
- package/build/index.js +53 -10
- package/build/mutation-buffer.d.ts +222 -0
- package/build/mutation-buffer.js +491 -0
- package/build/native-engine.d.ts +182 -0
- package/build/native-engine.js +178 -0
- package/build/native-tree-host.d.ts +25 -0
- package/build/native-tree-host.js +66 -0
- package/build/node.d.ts +172 -57
- package/build/node.js +839 -383
- package/build/pan-responder/index.js +27 -52
- package/build/platform-color/index.d.ts +1 -1
- package/build/platform-color/index.js +11 -4
- package/build/process-background-image/index.js +30 -566
- package/build/process-background-longhands.d.ts +4 -0
- package/build/process-background-longhands.js +44 -0
- package/build/process-box-shadow/index.js +23 -187
- package/build/process-filter.js +27 -300
- package/build/process-transform/index.d.ts +1 -1
- package/build/process-transform/index.js +25 -107
- package/build/process-transform-origin/index.d.ts +1 -1
- package/build/process-transform-origin/index.js +29 -102
- package/build/registry.d.ts +36 -0
- package/build/registry.js +73 -0
- package/build/sound-manager/index.d.ts +3 -0
- package/build/sound-manager/index.js +36 -0
- package/build/structured-style.d.ts +10 -0
- package/build/structured-style.js +180 -0
- package/build/style-registry/index.d.ts +14 -0
- package/build/style-registry/index.js +60 -11
- package/build/surface.d.ts +31 -2
- package/build/surface.js +138 -56
- package/build/text-input-state.d.ts +1 -0
- package/build/text-input-state.js +17 -3
- package/build/tree-host.d.ts +307 -0
- package/build/tree-host.js +211 -0
- package/build/view-config.js +4 -4
- package/codegen-specs/NativeSymbioteEngine.ts +27 -0
- package/cpp/SymbioteDebug.cpp +51 -0
- package/cpp/SymbioteDebug.h +54 -0
- package/cpp/SymbioteEngineBindings.cpp +232 -0
- package/cpp/SymbioteEngineBindings.h +59 -0
- package/cpp/SymbioteFabricProps.cpp +2619 -0
- package/cpp/SymbioteFabricProps.h +223 -0
- package/cpp/SymbioteTree.cpp +2478 -0
- package/cpp/SymbioteTree.h +257 -0
- package/ios/SymbioteEngineModule.h +25 -0
- package/ios/SymbioteEngineModule.mm +44 -0
- package/package.json +31 -3
- package/react-native.config.cjs +23 -0
- package/symbiote-engine.podspec +42 -0
- package/build/animated/bezier.d.ts +0 -1
- package/build/animated/bezier.js +0 -102
- package/build/commit.d.ts +0 -49
- package/build/commit.js +0 -1058
- package/build/tags.d.ts +0 -2
- 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
|
|
62
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/build/surface.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
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 {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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
|
-
|
|
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.
|
|
19
|
-
child.parent = undefined;
|
|
20
|
-
this.children.push(child);
|
|
68
|
+
appendChild(this.node, child);
|
|
21
69
|
}
|
|
22
70
|
insertBefore(child, beforeChild) {
|
|
23
|
-
this.
|
|
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
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// decides.
|
|
31
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 './
|
|
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
|
+
}
|