@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.
@@ -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 { markDirty } from './node.js';
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 dirty-mark - otherwise a node pulled out of a subtree here leaves that subtree
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);
@@ -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.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.2.0"
46
+ "@symbiote-native/test-utils": "0.3.0"
37
47
  },
38
48
  "scripts": {
39
49
  "typecheck": "tsc --build",