@symbiote-native/engine 0.3.0 → 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.
- package/README.md +7 -1
- package/build/accessibility-props.d.ts +19 -0
- package/build/accessibility-props.js +209 -0
- package/build/app-registry/index.d.ts +1 -0
- package/build/app-registry/index.js +15 -5
- package/build/commit.d.ts +22 -0
- package/build/commit.js +479 -76
- package/build/debug.js +10 -4
- package/build/events/index.js +124 -81
- package/build/fabric-props.js +163 -9
- package/build/fabric.d.ts +10 -3
- package/build/fabric.js +11 -2
- package/build/host-behavior.d.ts +47 -0
- package/build/host-behavior.js +262 -0
- package/build/host-instance/index.d.ts +2 -10
- package/build/host-instance/index.js +13 -46
- package/build/index.d.ts +6 -1
- package/build/index.js +11 -4
- package/build/node.d.ts +96 -2
- package/build/node.js +474 -58
- package/build/style-registry/index.d.ts +2 -0
- package/build/style-registry/index.js +79 -0
- package/build/surface.js +7 -4
- package/build/view-config.js +6 -0
- package/package.json +12 -2
|
@@ -12,5 +12,7 @@ export interface IStyleRule {
|
|
|
12
12
|
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
|
+
export declare function canonicalClassName(className: IClassNameValue): IClassNameValue;
|
|
15
16
|
export declare function resolveClassName(className: IClassNameValue): IResolvedStyle;
|
|
17
|
+
export declare function resolveActiveClassName(className: IClassNameValue): IResolvedStyle;
|
|
16
18
|
export {};
|
|
@@ -61,16 +61,40 @@ const resolvedCache = new Map();
|
|
|
61
61
|
// Overflow drops everything rather than evicting one entry: the cache is a warm-up optimization,
|
|
62
62
|
// not a working set, and an LRU's bookkeeping costs more than the rebuild it saves.
|
|
63
63
|
const RESOLVED_CACHE_LIMIT = 512;
|
|
64
|
+
// The pressed variant, keyed by the SAME authored string. Separate from `resolvedCache` so the two
|
|
65
|
+
// can never collide on one key, and string-keyed for the same reason that one is: identity is the
|
|
66
|
+
// whole point. `isAlreadyPublished` (node.ts) turns a re-push away by comparing slot 0 with
|
|
67
|
+
// Object.is, so a thousand unpressed rows only cost zero writes if they all publish the SAME
|
|
68
|
+
// object. Resolve an array or an object instead and every call returns a fresh one, the guard can
|
|
69
|
+
// never fire, and the re-push storm it was added for comes back with a new cause.
|
|
70
|
+
//
|
|
71
|
+
// This doubles the distinct strings a screen can hold. A SECOND state would multiply them, and the
|
|
72
|
+
// cache clears WHOLE on overflow rather than evicting — so a drop breaks identity for every node at
|
|
73
|
+
// once. That is the wall a `:focus` or `:hover` would hit, and it would read as an unexplained
|
|
74
|
+
// regression rather than as a cache limit.
|
|
75
|
+
const activeCache = new Map();
|
|
76
|
+
// The token the css-parser emits for `:active` (its `STATE_TOKEN`). Duplicated as a literal rather
|
|
77
|
+
// than imported: the engine does not depend on the parser, and a CSS identifier cannot carry an
|
|
78
|
+
// unescaped `:`, so this can never collide with a real class name.
|
|
79
|
+
const STATE_TOKEN = ':active';
|
|
80
|
+
// Whether ANY registered rule carries the state token. Not derivable from `ruleIndex`, which hooks
|
|
81
|
+
// a rule on `tokens[0]` — for `.btn:active` that is `btn`, so `ruleIndex.has(':active')` is always
|
|
82
|
+
// false and a gate built on it silently disables the whole feature. Found by a test, not by
|
|
83
|
+
// reading: the wrong gate makes pressing a no-op with everything green.
|
|
84
|
+
let hasActiveRules = false;
|
|
64
85
|
// Every registration is a cascade change, so nothing resolved before it can be trusted. Clearing
|
|
65
86
|
// wholesale beats versioning each entry — registration happens at import time, resolution happens
|
|
66
87
|
// per commit, and only the second one is hot.
|
|
67
88
|
function invalidateResolved() {
|
|
68
89
|
resolvedCache.clear();
|
|
90
|
+
activeCache.clear();
|
|
69
91
|
}
|
|
70
92
|
export function registerRules(rules) {
|
|
71
93
|
invalidateResolved();
|
|
72
94
|
const epoch = ruleEpoch++;
|
|
73
95
|
for (const rule of rules) {
|
|
96
|
+
if (rule.tokens.includes(STATE_TOKEN))
|
|
97
|
+
hasActiveRules = true;
|
|
74
98
|
const hook = rule.tokens[0];
|
|
75
99
|
// The empty set is a subset of every element's tokens, so a token-less rule would paint
|
|
76
100
|
// everything. It names no class and can only come from a broken compile.
|
|
@@ -87,6 +111,7 @@ export function registerRules(rules) {
|
|
|
87
111
|
// Used between tests to reset registry state.
|
|
88
112
|
export function clearGlobalStyles() {
|
|
89
113
|
ruleIndex.clear();
|
|
114
|
+
hasActiveRules = false;
|
|
90
115
|
ruleEpoch = 0;
|
|
91
116
|
invalidateResolved();
|
|
92
117
|
}
|
|
@@ -97,6 +122,26 @@ export function clearGlobalStyles() {
|
|
|
97
122
|
export function isClassNameValue(value) {
|
|
98
123
|
return (typeof value === 'string' || (typeof value === 'object' && value !== null));
|
|
99
124
|
}
|
|
125
|
+
// An ARRAY of plain strings joined into one, so it takes the memoised string path like any other
|
|
126
|
+
// class. Everything downstream keys off identity — `resolveClassName`'s cache, the `:active`
|
|
127
|
+
// variant, and `isAlreadyPublished`'s Object.is on slot 0 — and the array branch below reduces
|
|
128
|
+
// into a FRESH object on every call, so an array class silently opted out of all three: no pressed
|
|
129
|
+
// styling, and a re-push that re-dirties the node on every update.
|
|
130
|
+
//
|
|
131
|
+
// Only an ALL-STRING array converts. A mixed array carries IResolvedStyle entries — the channel
|
|
132
|
+
// ScrollView / VirtualizedList / FlatList / ImageBackground use to pass a style through the class
|
|
133
|
+
// prop — and those are not tokens; folding them into a string would be a category error rather
|
|
134
|
+
// than a fix. No adapter produces an all-string array today (Vue's createVNode normalises class to
|
|
135
|
+
// a string, Angular joins its accumulated tokens, React's className is a string by convention), so
|
|
136
|
+
// this closes a gap an APPLICATION can reach rather than one we can.
|
|
137
|
+
export function canonicalClassName(className) {
|
|
138
|
+
if (typeof className === 'string' || !Array.isArray(className)) {
|
|
139
|
+
return className;
|
|
140
|
+
}
|
|
141
|
+
return className.every(part => typeof part === 'string')
|
|
142
|
+
? className.join(' ')
|
|
143
|
+
: className;
|
|
144
|
+
}
|
|
100
145
|
export function resolveClassName(className) {
|
|
101
146
|
if (!className)
|
|
102
147
|
return {};
|
|
@@ -104,6 +149,11 @@ export function resolveClassName(className) {
|
|
|
104
149
|
return className;
|
|
105
150
|
}
|
|
106
151
|
if (Array.isArray(className)) {
|
|
152
|
+
// Direct callers (an adapter resolving a class prop itself) get the same canonicalisation as
|
|
153
|
+
// routeProp's path, so an all-string array is memoised for them too.
|
|
154
|
+
const canonical = canonicalClassName(className);
|
|
155
|
+
if (typeof canonical === 'string')
|
|
156
|
+
return resolveClassName(canonical);
|
|
107
157
|
return className.reduce((acc, item) => {
|
|
108
158
|
return { ...acc, ...resolveClassName(item) };
|
|
109
159
|
}, {});
|
|
@@ -117,6 +167,35 @@ export function resolveClassName(className) {
|
|
|
117
167
|
resolvedCache.set(className, resolved);
|
|
118
168
|
return resolved;
|
|
119
169
|
}
|
|
170
|
+
// The same element's style with `:active` added to its token list — so a `.btn:active` rule joins
|
|
171
|
+
// the cascade exactly as its specificity says, merged by the SAME matcher. That makes the result a
|
|
172
|
+
// complete REPLACEMENT for the unpressed style rather than an overlay, which is why no extra style
|
|
173
|
+
// slot is needed on the node: pressing swaps what sits in slot 0.
|
|
174
|
+
//
|
|
175
|
+
// Non-string input resolves to the plain style, unchanged. An object or array has no stable key to
|
|
176
|
+
// cache under, and the guard above is worthless without one.
|
|
177
|
+
export function resolveActiveClassName(className) {
|
|
178
|
+
if (typeof className !== 'string')
|
|
179
|
+
return resolveClassName(className);
|
|
180
|
+
// No `:active` rule anywhere in the app — hand back the very SAME object the unpressed path
|
|
181
|
+
// returns, not an equal one. Two cache keys give two objects even when their contents are
|
|
182
|
+
// byte-identical, and `isAlreadyPublished` compares with Object.is: pressing a node that has no
|
|
183
|
+
// pressed styling would otherwise publish a fresh base, dirty the node and pay a walk for a
|
|
184
|
+
// payload Fabric then rejects as unchanged. One node rather than a thousand, so not a storm —
|
|
185
|
+
// but it is a cost for literally nothing, and this makes the whole feature free for every app
|
|
186
|
+
// that does not use it.
|
|
187
|
+
if (!hasActiveRules)
|
|
188
|
+
return resolveClassName(className);
|
|
189
|
+
const cached = activeCache.get(className);
|
|
190
|
+
if (cached !== undefined)
|
|
191
|
+
return cached;
|
|
192
|
+
const parts = className.trim().split(/\s+/).filter(Boolean);
|
|
193
|
+
const resolved = parts.length === 0 ? {} : (matchRules([...parts, STATE_TOKEN]) ?? {});
|
|
194
|
+
if (activeCache.size >= RESOLVED_CACHE_LIMIT)
|
|
195
|
+
activeCache.clear();
|
|
196
|
+
activeCache.set(className, resolved);
|
|
197
|
+
return resolved;
|
|
198
|
+
}
|
|
120
199
|
function resolveClassString(className) {
|
|
121
200
|
const parts = className.trim().split(/\s+/).filter(Boolean);
|
|
122
201
|
if (parts.length === 0)
|
package/build/surface.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { commitChildren } from './commit.js';
|
|
5
5
|
import { dlog } from './debug.js';
|
|
6
6
|
import { installEventHandler } from './events/index.js';
|
|
7
|
-
import {
|
|
7
|
+
import { markStructureDirty } from './node.js';
|
|
8
8
|
export class SymbioteSurface {
|
|
9
9
|
rootTag;
|
|
10
10
|
children = [];
|
|
@@ -48,16 +48,19 @@ export class SymbioteSurface {
|
|
|
48
48
|
});
|
|
49
49
|
}
|
|
50
50
|
// Splices `parent.children` directly instead of going through node.ts's removeChild, so it
|
|
51
|
-
// owes the same
|
|
52
|
-
// looking clean and the commit walk skips right over the hole
|
|
51
|
+
// owes the same marks - otherwise a node pulled out of a subtree here leaves that subtree
|
|
52
|
+
// looking clean and the commit walk skips right over the hole, and commitTargeted would rebuild
|
|
53
|
+
// that parent's child set from a snapshot that still contains the removed node.
|
|
53
54
|
detach(child) {
|
|
54
55
|
const parent = child.parent;
|
|
55
56
|
if (parent) {
|
|
57
|
+
// Marks before the splice, like node.ts's own structural ops: the committed record may be
|
|
58
|
+
// aliasing `parent.children`, and this call is what copies it out of the way.
|
|
59
|
+
markStructureDirty(parent);
|
|
56
60
|
const index = parent.children.indexOf(child);
|
|
57
61
|
if (index >= 0)
|
|
58
62
|
parent.children.splice(index, 1);
|
|
59
63
|
child.parent = undefined;
|
|
60
|
-
markDirty(parent);
|
|
61
64
|
return;
|
|
62
65
|
}
|
|
63
66
|
const topIndex = this.children.indexOf(child);
|
package/build/view-config.js
CHANGED
|
@@ -30,6 +30,12 @@ const BASE_EVENTS = [
|
|
|
30
30
|
'press',
|
|
31
31
|
'pressIn',
|
|
32
32
|
'pressOut',
|
|
33
|
+
// Synthesized from the touch stream like its four siblings, and omitted here until 2026-09-02.
|
|
34
|
+
// A name the press machine OWNS but the engine does not route is dead on the lowered path only:
|
|
35
|
+
// `routeProp` hands an `on*` prop to `setEventListener` (and thus to the behavior's stash) only
|
|
36
|
+
// for a registered event, so `onPressMove` landed in `node.props` where nothing reads it, while
|
|
37
|
+
// a wrapper passes the same callback to the machine directly and stayed correct.
|
|
38
|
+
'pressMove',
|
|
33
39
|
'longPress',
|
|
34
40
|
'layout',
|
|
35
41
|
'focus',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@symbiote-native/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "SymbioteNative's retained shadow-tree engine — clone-on-write commit path + event normalization over React Native Fabric, shared by every framework adapter.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -17,6 +17,16 @@
|
|
|
17
17
|
"main": "./build/index.js",
|
|
18
18
|
"module": "./build/index.js",
|
|
19
19
|
"types": "./build/index.d.ts",
|
|
20
|
+
"keywords": [
|
|
21
|
+
"react-native",
|
|
22
|
+
"symbiote-native",
|
|
23
|
+
"fabric",
|
|
24
|
+
"shadow-tree",
|
|
25
|
+
"renderer-core",
|
|
26
|
+
"jsi",
|
|
27
|
+
"yoga",
|
|
28
|
+
"cross-platform"
|
|
29
|
+
],
|
|
20
30
|
"exports": {
|
|
21
31
|
".": {
|
|
22
32
|
"types": "./build/index.d.ts",
|
|
@@ -33,7 +43,7 @@
|
|
|
33
43
|
"react-native": ">=0.86"
|
|
34
44
|
},
|
|
35
45
|
"devDependencies": {
|
|
36
|
-
"@symbiote-native/test-utils": "0.
|
|
46
|
+
"@symbiote-native/test-utils": "0.3.0"
|
|
37
47
|
},
|
|
38
48
|
"scripts": {
|
|
39
49
|
"typecheck": "tsc --build",
|