@symbiote-native/engine 0.2.0 → 0.3.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 (64) hide show
  1. package/README.md +17 -10
  2. package/build/action-sheet-ios/index.js +4 -1
  3. package/build/animated/animations/composition.js +3 -1
  4. package/build/animated/animations/spring-config.js +4 -1
  5. package/build/animated/animations/spring.js +23 -9
  6. package/build/animated/animations/timing.js +3 -2
  7. package/build/animated/bezier.js +2 -1
  8. package/build/animated/color.js +4 -2
  9. package/build/animated/event.js +7 -3
  10. package/build/animated/graph.js +8 -3
  11. package/build/animated/index.d.ts +2 -2
  12. package/build/animated/index.js +2 -2
  13. package/build/animated/interpolation.js +7 -2
  14. package/build/animated/mock.js +1 -1
  15. package/build/animated/native/native-animated.js +1 -1
  16. package/build/animated/operators.js +27 -6
  17. package/build/animated/props.js +1 -1
  18. package/build/animated/rgba.js +18 -3
  19. package/build/animated/style.js +13 -3
  20. package/build/animated/value-xy.d.ts +8 -5
  21. package/build/animated/value-xy.js +4 -1
  22. package/build/appearance/index.js +4 -1
  23. package/build/commit.d.ts +13 -0
  24. package/build/commit.js +145 -9
  25. package/build/events/index.js +19 -8
  26. package/build/fabric-props.js +8 -2
  27. package/build/image-loader.js +3 -2
  28. package/build/index.d.ts +19 -17
  29. package/build/index.js +17 -10
  30. package/build/interaction-manager/index.js +2 -1
  31. package/build/layout-animation/index.js +12 -4
  32. package/build/native-events.js +2 -1
  33. package/build/native-modules/index.js +3 -1
  34. package/build/node.d.ts +25 -0
  35. package/build/node.js +168 -2
  36. package/build/pan-responder/index.js +20 -9
  37. package/build/permissions-android/index.js +3 -1
  38. package/build/platform/index.android.d.ts +1 -1
  39. package/build/platform/index.android.js +1 -1
  40. package/build/platform/index.ios.d.ts +1 -1
  41. package/build/platform-color/index.js +3 -1
  42. package/build/post-commit.d.ts +1 -0
  43. package/build/post-commit.js +6 -0
  44. package/build/process-background-image/index.js +30 -9
  45. package/build/process-filter.js +11 -4
  46. package/build/registry.js +10 -2
  47. package/build/report-error.js +4 -1
  48. package/build/share/index.android.js +1 -1
  49. package/build/share/index.ios.js +1 -1
  50. package/build/status-bar/index.android.js +3 -4
  51. package/build/status-bar/index.ios.js +1 -1
  52. package/build/style-registry/index.d.ts +9 -2
  53. package/build/style-registry/index.js +145 -170
  54. package/build/style-registry/scope.d.ts +15 -1
  55. package/build/style-registry/scope.js +29 -27
  56. package/build/styles.d.ts +11 -1
  57. package/build/surface.d.ts +1 -1
  58. package/build/surface.js +5 -0
  59. package/build/tags.d.ts +1 -0
  60. package/build/tags.js +31 -1
  61. package/build/touch-history.js +9 -2
  62. package/build/vibration/index.ios.js +1 -1
  63. package/build/view-config.js +14 -2
  64. package/package.json +2 -2
@@ -12,7 +12,7 @@
12
12
  import { getNativeModule } from '../native-modules/index.js';
13
13
  import { dlog } from '../debug.js';
14
14
  import { processColor } from '../platform-color/index.js';
15
- import { STATUS_BAR_MANAGER } from './shared.js';
15
+ import { STATUS_BAR_MANAGER, } from './shared.js';
16
16
  // processColor returns `unknown` (its result is platform-dependent); narrow to the
17
17
  // number Fabric/native expects, like RN's invariant before setColor. A non-number
18
18
  // (null for an unparseable color, headless identity passthrough of a string) is dropped.
@@ -31,7 +31,7 @@ function applyBackgroundColor(manager, color, animated) {
31
31
  // single-arg Android setters. The adapter calls this from its declarative component's effect,
32
32
  // on mount and on every prop change.
33
33
  export function applyStatusBarProps(props) {
34
- const { barStyle, hidden, animated = false, backgroundColor, translucent } = props;
34
+ const { barStyle, hidden, animated = false, backgroundColor, translucent, } = props;
35
35
  const manager = getNativeModule(STATUS_BAR_MANAGER);
36
36
  if (manager === null) {
37
37
  dlog('StatusBar android: StatusBarManager not resolvable — skipping');
@@ -77,6 +77,5 @@ export const statusBarImperative = {
77
77
  // constant is absent (older RN, or a fake that doesn't define getConstants). Read lazily
78
78
  // so nothing touches native at import time.
79
79
  export function statusBarCurrentHeight() {
80
- return getNativeModule(STATUS_BAR_MANAGER)?.getConstants?.()
81
- .HEIGHT;
80
+ return getNativeModule(STATUS_BAR_MANAGER)?.getConstants?.().HEIGHT;
82
81
  }
@@ -21,7 +21,7 @@ import { STATUS_BAR_MANAGER, STATIC_HIDE_TRANSITION, hideTransition, } from './s
21
21
  // (deepest/last wins); we direct-apply a single component's props, which is correct for one
22
22
  // StatusBar and a fine first cut.
23
23
  export function applyStatusBarProps(props) {
24
- const { barStyle, hidden, animated = false, networkActivityIndicatorVisible } = props;
24
+ const { barStyle, hidden, animated = false, networkActivityIndicatorVisible, } = props;
25
25
  const manager = getNativeModule(STATUS_BAR_MANAGER);
26
26
  if (manager === null) {
27
27
  dlog('StatusBar: StatusBarManager not resolvable via __turboModuleProxy — skipping');
@@ -1,9 +1,16 @@
1
1
  import type { IViewStyle, ITextStyle } from '../styles';
2
2
  type IResolvedStyle = Partial<IViewStyle & ITextStyle>;
3
3
  export type IClassNameValue = string | IResolvedStyle | Array<string | IResolvedStyle> | undefined | null;
4
- export declare function registerStyles(styles: Record<string, IResolvedStyle>): void;
4
+ export interface IStyleRule {
5
+ /** Class names AS AUTHORED / as renamed by the scoping pass. No normalization. */
6
+ readonly tokens: readonly string[];
7
+ readonly specificity: readonly [number, number, number];
8
+ /** Source order within its file; the registry adds a per-registration epoch on top. */
9
+ readonly order: number;
10
+ readonly style: IResolvedStyle;
11
+ }
12
+ export declare function registerRules(rules: readonly IStyleRule[]): void;
5
13
  export declare function clearGlobalStyles(): void;
6
14
  export declare function isClassNameValue(value: unknown): value is IClassNameValue;
7
15
  export declare function resolveClassName(className: IClassNameValue): IResolvedStyle;
8
- export declare function kebabToCamel(value: string): string;
9
16
  export {};
@@ -1,55 +1,101 @@
1
1
  // Runtime style registry. Side-effect CSS imports (compiled by the sibling CSS-to-style
2
- // build package, not this module) call registerStyles() with camelCase keys; components
3
- // look them up via resolveClassName(). No CSS parsing here - just a Map<string, ...> lookup.
2
+ // build package, not this module) call registerRules() with the token SET each selector
3
+ // names; components look them up via resolveClassName(). No CSS parsing here - just an
4
+ // inverted Map<token, rules> and a subset test.
4
5
  //
5
- // No Tailwind-utility detection layer: this repo's style surface has no Tailwind layer,
6
- // so the compound lookup below always runs for 2-4-part class strings instead of being
7
- // gated behind "no part looks like a utility class".
6
+ // A rule matches when its tokens are a SUBSET of the element's class list, in any order and
7
+ // at any length, and the matches cascade by (derived-scope, specificity, epoch, order).
8
+ // That replaced an earlier flat Map<key, style>, where the compiler COLLAPSED a selector's
9
+ // tokens into one camelCase name (`.a.b` -> `aB`) and the runtime guessed the split back
10
+ // apart by permuting the class string: 60 built keys on a 4-token miss, a camelCase
11
+ // round-trip that could not survive a scope suffix, no real cascade, and a silent cliff at
12
+ // 5 tokens where the compound branch stopped running at all. Tokens now arrive AS AUTHORED,
13
+ // so there is nothing left to guess.
14
+ // The scope tail as OUR OWN compilers emit it — all three shapes, the hash being css-parser's
15
+ // base36 hashFilePath:
16
+ // `card__data-v-<h>` Vue `<style scoped>`
17
+ // `card__svelte-<h>` Svelte `<style>`
18
+ // `card__module__<h>` CSS Modules — BOTH a standalone `.module.css` and Vue's `<style module>`
19
+ // (A preprocessor source — SCSS/Less/Stylus — reduces to plain CSS before any of this, so it
20
+ // carries whichever of the three its host block uses; there is no fourth shape.)
8
21
  //
9
- // kebab-case authoring: a CSS selector `.section-label` always registers under the
10
- // camelCase key `sectionLabel` (@symbiote-native/css-parser's extractClassName), so a template
11
- // can write EITHER `class="sectionLabel"` OR `class="section-label"` - resolveOne below
12
- // falls back to the kebab->camel form on a miss, since authors don't reliably write the
13
- // exact camelCase key.
14
- // Compound lookup tries every ordering of 2-4 space-separated class parts (e.g. "btn primary"
15
- // -> "btnPrimary" / "primaryBtn"), mirroring CSS compound-selector registration
16
- // (`.btn.primary { }`), and layers whatever it finds ON TOP of the per-class merge.
17
- const COMPOUND_MIN_PARTS = 2;
18
- const COMPOUND_MAX_PARTS = 4;
19
- // A scope suffix as the `<style scoped>` compilers emit it: `card__data-v-1a2b3c4d` (Vue) or
20
- // `card__svelte-1a2b3c4d` (Svelte), the hash being css-parser's base36 hashFilePath. Matched by
21
- // SHAPE, never by "there is a `__` somewhere in the name" — a BEM class (`card__title`) must
22
- // never be read as a scoped `card`, which would silently merge the block's styles into the
23
- // element's.
24
- const SCOPE_SEPARATOR = '__';
25
- const SCOPE_SUFFIX_PATTERN = /^(?:data-v|svelte)-[0-9a-z]+$/;
22
+ // Matched by SHAPE, never by "there is a `__` somewhere in the name" — a BEM class
23
+ // (`card__title`) must never be read as a scoped `card`, which would silently merge the block's
24
+ // styles into the element's.
25
+ //
26
+ // Matched as ONE TAIL UNIT anchored at the end, rather than as "split on the last `__`, then
27
+ // shape-test what follows": the module form carries its own `__`, so a `lastIndexOf` split cuts
28
+ // `badge__module__<h>` into base `badge__module` + scope `<h>` and the base it hands back names
29
+ // no class. Widening the shape test alone would NOT fix that — the split POINT is wrong, not the
30
+ // alphabet.
31
+ //
32
+ // Note this suffix scheme is ours, not the frameworks'. Svelte appends a SEPARATE token
33
+ // (`class="card svelte-<h>"`) and Vue `<style scoped>` renames nothing at all (it adds a
34
+ // `data-v-<h>` attribute). We have no DOM and no attribute matching, so a scope is expressed by
35
+ // renaming the token; the names only borrow their vocabulary.
36
+ const SCOPE_TAIL_PATTERN = /__((?:data-v|svelte)-[0-9a-z]+|module__[0-9a-z]+)$/;
26
37
  function splitScopedToken(token) {
27
- const separator = token.lastIndexOf(SCOPE_SEPARATOR);
28
- if (separator <= 0)
38
+ const match = SCOPE_TAIL_PATTERN.exec(token);
39
+ // index 0 would mean the token is nothing BUT a scope tail, which names no class.
40
+ if (match === null || match.index <= 0)
29
41
  return null;
30
- const scope = token.slice(separator + SCOPE_SEPARATOR.length);
31
- if (!SCOPE_SUFFIX_PATTERN.test(scope))
32
- return null;
33
- return { base: token.slice(0, separator), scope };
42
+ return { base: token.slice(0, match.index), scope: match[1] };
43
+ }
44
+ // Inverted index, ONE bucket per rule rather than one per token: a rule can only match when the
45
+ // element carries every one of its tokens, so any single token of it is a sufficient hook. The
46
+ // alternative (indexing under all of them) surfaces the same rule once per shared token and buys
47
+ // nothing but a dedupe pass.
48
+ const ruleIndex = new Map();
49
+ let ruleEpoch = 0;
50
+ // resolveClassName runs from routeProp on every class-prop WRITE, per node, and a screen only
51
+ // ever uses a few dozen distinct class strings — so after warm-up the whole resolution collapses
52
+ // to one Map.get. Only the STRING branch is memoized: an object/array argument has no stable key
53
+ // (a fresh literal every render) and would just fill the map.
54
+ //
55
+ // Safe to hand the SAME object out repeatedly because no caller mutates it — every consumer
56
+ // (routeProp's commitClassStyle, and the ScrollView / VirtualizedList / FlatList / ImageBackground
57
+ // class-to-style props in all five adapters) spreads it or drops it into a style array, and
58
+ // flattenStyle shallow-copies.
59
+ const resolvedCache = new Map();
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.
63
+ const RESOLVED_CACHE_LIMIT = 512;
64
+ // Every registration is a cascade change, so nothing resolved before it can be trusted. Clearing
65
+ // wholesale beats versioning each entry — registration happens at import time, resolution happens
66
+ // per commit, and only the second one is hot.
67
+ function invalidateResolved() {
68
+ resolvedCache.clear();
34
69
  }
35
- const globalStyles = new Map();
36
- // Called by generated code from side-effect style imports. Last import wins on a
37
- // name collision, matching CSS cascade behavior.
38
- export function registerStyles(styles) {
39
- for (const [name, style] of Object.entries(styles)) {
40
- globalStyles.set(name, style);
70
+ export function registerRules(rules) {
71
+ invalidateResolved();
72
+ const epoch = ruleEpoch++;
73
+ for (const rule of rules) {
74
+ const hook = rule.tokens[0];
75
+ // The empty set is a subset of every element's tokens, so a token-less rule would paint
76
+ // everything. It names no class and can only come from a broken compile.
77
+ if (hook === undefined)
78
+ continue;
79
+ const bucket = ruleIndex.get(hook);
80
+ if (bucket === undefined) {
81
+ ruleIndex.set(hook, [{ rule, epoch }]);
82
+ continue;
83
+ }
84
+ bucket.push({ rule, epoch });
41
85
  }
42
86
  }
43
87
  // Used between tests to reset registry state.
44
88
  export function clearGlobalStyles() {
45
- globalStyles.clear();
89
+ ruleIndex.clear();
90
+ ruleEpoch = 0;
91
+ invalidateResolved();
46
92
  }
47
93
  // A `class`/`className` prop arrives as `unknown` at the routeProp boundary (any adapter can
48
94
  // hand over anything); this narrows before resolveClassName without an `as` cast. Shared with
49
95
  // adapters/vue/src/components/scroll-view/shared.ts's identical need, rather than each keeping
50
96
  // its own copy - that file imports this one instead of redeclaring it.
51
97
  export function isClassNameValue(value) {
52
- return typeof value === 'string' || (typeof value === 'object' && value !== null);
98
+ return (typeof value === 'string' || (typeof value === 'object' && value !== null));
53
99
  }
54
100
  export function resolveClassName(className) {
55
101
  if (!className)
@@ -62,146 +108,75 @@ export function resolveClassName(className) {
62
108
  return { ...acc, ...resolveClassName(item) };
63
109
  }, {});
64
110
  }
65
- const trimmed = className.trim();
66
- if (!trimmed)
67
- return {};
68
- const parts = trimmed.split(/\s+/).filter(Boolean);
69
- // A single token is nothing but the exact-match lookup, so it goes through resolveOne, which
70
- // adds the scoped-token base layering below. A multi-token string still tries the whole string
71
- // as one key first — `$style.card`-style output arrives pre-resolved and must not be split.
72
- if (parts.length <= 1)
73
- return resolveOne(trimmed);
74
- const exactMatch = lookupKey(trimmed);
75
- if (exactMatch)
76
- return exactMatch;
77
- const merged = parts.reduce((acc, cls) => {
78
- return { ...acc, ...resolveOne(cls) };
79
- }, {});
80
- // A compound rule LAYERS OVER the single-class rules rather than replacing them, matching the
81
- // cascade: `.card { padding: 8; background: white }` + `.card.big { padding: 16 }` on
82
- // `class="card big"` is padding 16 AND background white. Returning the compound alone (what
83
- // this did before) silently dropped every property the compound did not itself restate.
84
- if (parts.length >= COMPOUND_MIN_PARTS && parts.length <= COMPOUND_MAX_PARTS) {
85
- const compound = tryCompoundLookup(parts);
86
- if (compound)
87
- return { ...merged, ...compound };
88
- }
89
- return merged;
90
- }
91
- function generateCompoundPermutations(parts) {
92
- if (parts.length < COMPOUND_MIN_PARTS)
93
- return [];
94
- const compounds = [];
95
- for (let size = COMPOUND_MIN_PARTS; size <= parts.length; size++) {
96
- compounds.push(...generateKPermutations(parts, size));
97
- }
98
- return compounds;
111
+ const cached = resolvedCache.get(className);
112
+ if (cached !== undefined)
113
+ return cached;
114
+ const resolved = resolveClassString(className);
115
+ if (resolvedCache.size >= RESOLVED_CACHE_LIMIT)
116
+ resolvedCache.clear();
117
+ resolvedCache.set(className, resolved);
118
+ return resolved;
99
119
  }
100
- function generateKPermutations(parts, size) {
101
- if (size === 0)
102
- return [[]];
120
+ function resolveClassString(className) {
121
+ const parts = className.trim().split(/\s+/).filter(Boolean);
103
122
  if (parts.length === 0)
104
- return [];
105
- const result = [];
106
- function helper(current, remaining, depth) {
107
- if (depth === size) {
108
- result.push(current);
109
- return;
110
- }
111
- for (let i = 0; i < remaining.length; i++) {
112
- helper([...current, remaining[i]], remaining.slice(0, i).concat(remaining.slice(i + 1)), depth + 1);
113
- }
114
- }
115
- helper([], parts, 0);
116
- return result;
117
- }
118
- // Compound permutations join as camelCase ("btn primary" -> "btnPrimary") because this
119
- // repo's CSS-to-style compiler emits plain camelCase keys for every class, single or
120
- // compound, so "btn primary" must resolve against a registered "btnPrimary".
121
- function toCompoundKey(parts) {
122
- return parts.reduce((key, part, index) => (index === 0 ? part : key + capitalize(part)), '');
123
- }
124
- function capitalize(value) {
125
- return value.length === 0 ? value : value[0].toUpperCase() + value.slice(1);
126
- }
127
- // Duplicated from @symbiote-native/css-parser's identical helper rather than imported: css-parser
128
- // pulls in postcss and is build-time only (never shipped in the app bundle), and this registry
129
- // is the opposite - pure runtime, in every app bundle - so importing it here would leak a
130
- // build-time dependency into the shipped app. The conversion itself is two lines; keeping both
131
- // copies in sync is a smaller cost than the alternative.
132
- //
133
- // Exported (not just local to this file) because ./scope.ts's Vue `<style scoped>` name
134
- // rewriter needs the same kebab->camel normalization and is a different responsibility living
135
- // in a sibling module - see that file's own doc comment for why it's split out of this one.
136
- export function kebabToCamel(value) {
137
- return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
138
- }
139
- function tryCompoundLookup(parts) {
140
- if (parts.length < COMPOUND_MIN_PARTS)
141
- return null;
142
- for (const subset of generateCompoundPermutations(parts)) {
143
- for (const key of compoundKeysFor(subset)) {
144
- const style = globalStyles.get(key);
145
- if (style)
146
- return style;
147
- }
148
- }
149
- return null;
150
- }
151
- function compoundKeysFor(subset) {
152
- const scoped = scopedCompoundKey(subset);
153
- return scoped === null ? [toCompoundKey(subset)] : [toCompoundKey(subset), scoped];
123
+ return {};
124
+ return matchRules(parts) ?? {};
154
125
  }
155
- // The scope suffix is appended per TOKEN in the markup (`class="card__svelte-h big__svelte-h"`)
156
- // but appears ONCE, at the end, in the registered key a compound rule produces
157
- // (`.card.big` -> `cardBig__svelte-h`) — the compiler collapses the selector to one name and
158
- // suffixes that. Those two operations do not commute, so the key built from the raw tokens
159
- // (`card__svelte-hBig__svelte-h`) can never match and every scoped compound rule was dead.
160
- // Rebuild the key the way registration did: strip the suffix, join the bases, re-append once.
161
- //
162
- // An UNSCOPED token in the subset contributes its own name and no scope, rather than aborting.
163
- // That is the `:global()` case, and it is why a partial `:global()` reaches the markup it was
164
- // written for: `.card :global(.reset)` registers the key `cardReset`, which the scoper suffixes
165
- // as a whole (the escape hatch exempts the `reset` MARKUP TOKEN, not the collapsed key), while
166
- // the element carries `card__svelte-h reset`. The same shape covers a class handed down from a
167
- // parent component, which likewise arrives unsuffixed.
168
- //
169
- // Widening, stated so it stays deliberate: a fully-scoped `.card.reset` collapses to that same
170
- // key, so an element carrying a FOREIGN `reset` now matches a rule the author scoped to their
171
- // own. The key format cannot tell the two apart — telling them apart needs a registry indexed by
172
- // token set, with per-token scope. The scopes still have to agree: two tokens from two different
173
- // components have no single suffix to factor out and stay unmatched.
174
- function scopedCompoundKey(subset) {
175
- const bases = [];
176
- let scope;
177
- for (const token of subset) {
178
- const split = splitScopedToken(token);
179
- if (split === null) {
180
- bases.push(token);
126
+ // `null` rather than `{}` for "nothing matched", so the caller can skip the empty-object churn on
127
+ // every class-prop set that has no rules.
128
+ function matchRules(parts) {
129
+ const literal = new Set(parts);
130
+ const tokens = elementTokens(parts);
131
+ let matched = null;
132
+ for (const token of tokens) {
133
+ const bucket = ruleIndex.get(token);
134
+ if (bucket === undefined)
181
135
  continue;
136
+ for (const indexed of bucket) {
137
+ if (!indexed.rule.tokens.every(needed => tokens.has(needed)))
138
+ continue;
139
+ matched ??= [];
140
+ matched.push({
141
+ indexed,
142
+ derivedTokens: indexed.rule.tokens.filter(token => !literal.has(token))
143
+ .length,
144
+ });
182
145
  }
183
- if (scope !== undefined && split.scope !== scope)
184
- return null;
185
- scope = split.scope;
186
- bases.push(split.base);
187
146
  }
188
- return scope === undefined ? null : toCompoundKey(bases) + SCOPE_SEPARATOR + scope;
147
+ if (matched === null)
148
+ return null;
149
+ matched.sort(byCascadeOrder);
150
+ return matched.reduce((acc, match) => ({ ...acc, ...match.indexed.rule.style }), {});
189
151
  }
190
- // A scoped token layers over its own unscoped name: on the web the element carries BOTH classes
191
- // (`class="card svelte-h"`), so a global `.card` in App.css still applies underneath the
192
- // component's `<style>` rule. Rewriting `card` -> `card__svelte-h` here is how the scope is
193
- // expressed instead of a second class, so the base has to be re-consulted explicitly or that
194
- // global rule silently disappears the moment a component defines a class of the same name.
195
- function resolveOne(name) {
196
- const trimmed = name.trim();
197
- if (!trimmed)
198
- return {};
199
- const scoped = lookupKey(trimmed);
200
- const split = splitScopedToken(trimmed);
201
- if (split === null)
202
- return scoped ?? {};
203
- return { ...lookupKey(split.base), ...scoped };
152
+ // A scoped token contributes its BASE as well: the scope is expressed by RENAMING the token
153
+ // rather than adding a second one, so a global `.card` in App.css only survives a component
154
+ // defining its own `.card` if the base is put back into the set here. On the web the element
155
+ // would carry both names and no such step would exist.
156
+ function elementTokens(parts) {
157
+ const tokens = new Set();
158
+ for (const part of parts) {
159
+ tokens.add(part);
160
+ const split = splitScopedToken(part);
161
+ if (split !== null)
162
+ tokens.add(split.base);
163
+ }
164
+ return tokens;
204
165
  }
205
- function lookupKey(name) {
206
- return globalStyles.get(name) ?? globalStyles.get(kebabToCamel(name));
166
+ // Ascending, because the caller merges left to right: the winner has to be spread LAST. Ties
167
+ // break the way the cascade does — later import first, then later line in the file.
168
+ function byCascadeOrder(left, right) {
169
+ if (left.derivedTokens !== right.derivedTokens) {
170
+ return right.derivedTokens - left.derivedTokens;
171
+ }
172
+ const leftRule = left.indexed.rule;
173
+ const rightRule = right.indexed.rule;
174
+ for (let index = 0; index < leftRule.specificity.length; index++) {
175
+ const diff = leftRule.specificity[index] - rightRule.specificity[index];
176
+ if (diff !== 0)
177
+ return diff;
178
+ }
179
+ return left.indexed.epoch === right.indexed.epoch
180
+ ? leftRule.order - rightRule.order
181
+ : left.indexed.epoch - right.indexed.epoch;
207
182
  }
@@ -1,3 +1,17 @@
1
1
  export type IClassToggleMap = Record<string, boolean | undefined>;
2
2
  export type IScopableClassValue = string | IClassToggleMap | Array<string | IClassToggleMap> | undefined | null;
3
- export declare function scopeClassName(value: IScopableClassValue, localNames: ReadonlySet<string>, scopeId: string): IScopableClassValue;
3
+ export type IClassNameRenames = Readonly<Record<string, string>>;
4
+ /**
5
+ * Rewrites every class token the style compiler renamed, leaving the rest alone.
6
+ *
7
+ * The names come from the compiler's own rename table, emitted verbatim, rather than being
8
+ * RECOMPUTED here as `token + '__' + scopeId`. Recomputing made the runtime a second,
9
+ * independent implementation of "what is this class called now" beside the style compiler —
10
+ * they agreed by construction until a rename mechanism changed under one of them. lightningcss
11
+ * renames the class; this reads the result.
12
+ *
13
+ * A token absent from the table is left alone: it belongs to another file (a `:global()` escape
14
+ * hatch, App.css, a class handed down by a parent), and the registry resolves it under its own
15
+ * authored name.
16
+ */
17
+ export declare function renameClassTokens(value: IScopableClassValue, renames: IClassNameRenames): IScopableClassValue;
@@ -1,41 +1,43 @@
1
- // Vue `<style scoped>` class-name rewriter. Distinct responsibility from the sibling
2
- // ./index.ts (the CSS class -> style registry): this module does pure NAME rewriting,
3
- // no registry lookup, no CSS parsing. It runs at the compiled call site of a Vue SFC's
4
- // scoped-style template - `adapters/vue/metro-vue-transformer.cjs` emits calls to
5
- // scopeClassName (imported there as `__scopeClass`) - BEFORE Vue's own normalizeClass()
6
- // collapses string/object/array `class` values to a final string, so it must pre-process
7
- // all three shapes normalizeClass understands. resolveClassName in ./index.ts still does
8
- // the actual style lookup, unchanged, against the rewritten (possibly suffixed) name.
9
- import { kebabToCamel } from './index.js';
10
- // Suffixes every class token that this file's scoped block locally defines with
11
- // `__${scopeId}`, leaving unrecognized tokens (globals, external classes) untouched.
12
- export function scopeClassName(value, localNames, scopeId) {
1
+ // Class-name rewriter for scoped styles. Distinct responsibility from the sibling ./index.ts
2
+ // (the CSS class -> style registry): this module does pure NAME rewriting, no registry lookup,
3
+ // no CSS parsing. It runs at the compiled call site of a scoped-style template -
4
+ // `adapters/vue/metro-vue-transformer.cjs` emits calls to renameClassTokens (imported there as
5
+ // `__scopeClass`) - BEFORE Vue's own normalizeClass() collapses string/object/array `class`
6
+ // values to a final string, so it must pre-process all three shapes normalizeClass understands.
7
+ // resolveClassName in ./index.ts still does the actual style lookup, unchanged, against the
8
+ // rewritten name.
9
+ /**
10
+ * Rewrites every class token the style compiler renamed, leaving the rest alone.
11
+ *
12
+ * The names come from the compiler's own rename table, emitted verbatim, rather than being
13
+ * RECOMPUTED here as `token + '__' + scopeId`. Recomputing made the runtime a second,
14
+ * independent implementation of "what is this class called now" beside the style compiler —
15
+ * they agreed by construction until a rename mechanism changed under one of them. lightningcss
16
+ * renames the class; this reads the result.
17
+ *
18
+ * A token absent from the table is left alone: it belongs to another file (a `:global()` escape
19
+ * hatch, App.css, a class handed down by a parent), and the registry resolves it under its own
20
+ * authored name.
21
+ */
22
+ export function renameClassTokens(value, renames) {
13
23
  if (value === undefined || value === null)
14
24
  return value;
15
25
  if (Array.isArray(value)) {
16
- return value.map(item => scopeClassEntry(item, localNames, scopeId));
26
+ return value.map(item => renameClassEntry(item, renames));
17
27
  }
18
- return scopeClassEntry(value, localNames, scopeId);
28
+ return renameClassEntry(value, renames);
19
29
  }
20
- // A token arrives as either the camelCase registry key (`sectionLabel`) or its kebab-case
21
- // authoring form (`section-label`) - normalize to camelCase FIRST, then decide scoping, so
22
- // `localNames` (always camelCase, built from the css-parser's registered keys) recognizes a
23
- // kebab-written token. The emitted (possibly suffixed) name is always the camelCase form.
24
- function scopeToken(token, localNames, scopeId) {
25
- const camelToken = kebabToCamel(token);
26
- return localNames.has(camelToken) ? `${camelToken}__${scopeId}` : camelToken;
27
- }
28
- function scopeClassEntry(value, localNames, scopeId) {
30
+ function renameClassEntry(value, renames) {
29
31
  if (typeof value === 'object') {
30
- const scoped = {};
32
+ const renamed = {};
31
33
  for (const [name, enabled] of Object.entries(value)) {
32
- scoped[scopeToken(name, localNames, scopeId)] = enabled;
34
+ renamed[renames[name] ?? name] = enabled;
33
35
  }
34
- return scoped;
36
+ return renamed;
35
37
  }
36
38
  return value
37
39
  .split(/\s+/)
38
40
  .filter(Boolean)
39
- .map(token => scopeToken(token, localNames, scopeId))
41
+ .map(token => renames[token] ?? token)
40
42
  .join(' ');
41
43
  }
package/build/styles.d.ts CHANGED
@@ -187,7 +187,11 @@ export interface IViewStyle {
187
187
  shadowRadius?: number;
188
188
  elevation?: number;
189
189
  transform?: ITransformProp[];
190
- transformOrigin?: [string | number, string | number, string | number] | string;
190
+ transformOrigin?: [
191
+ string | number,
192
+ string | number,
193
+ string | number
194
+ ] | string;
191
195
  boxShadow?: IBoxShadowValue[] | string;
192
196
  filter?: IFilterFunction[] | string;
193
197
  experimental_backgroundImage?: IBackgroundImageValue[] | string;
@@ -207,6 +211,12 @@ export interface ITextStyle extends IViewStyle {
207
211
  textDecorationLine?: 'none' | 'underline' | 'line-through' | 'underline line-through';
208
212
  textDecorationColor?: IColorValue;
209
213
  textDecorationStyle?: 'solid' | 'double' | 'dotted' | 'dashed';
214
+ textShadowColor?: IColorValue;
215
+ textShadowOffset?: {
216
+ width: number;
217
+ height: number;
218
+ };
219
+ textShadowRadius?: number;
210
220
  fontVariant?: string[];
211
221
  writingDirection?: 'auto' | 'ltr' | 'rtl';
212
222
  includeFontPadding?: boolean;
@@ -1,5 +1,5 @@
1
1
  import type { IRootTag } from './fabric';
2
- import type { ISymbioteNode } from './node';
2
+ import { type ISymbioteNode } from './node';
3
3
  export declare class SymbioteSurface {
4
4
  readonly rootTag: IRootTag;
5
5
  readonly children: ISymbioteNode[];
package/build/surface.js CHANGED
@@ -4,6 +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
8
  export class SymbioteSurface {
8
9
  rootTag;
9
10
  children = [];
@@ -46,6 +47,9 @@ export class SymbioteSurface {
46
47
  this.commit();
47
48
  });
48
49
  }
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.
49
53
  detach(child) {
50
54
  const parent = child.parent;
51
55
  if (parent) {
@@ -53,6 +57,7 @@ export class SymbioteSurface {
53
57
  if (index >= 0)
54
58
  parent.children.splice(index, 1);
55
59
  child.parent = undefined;
60
+ markDirty(parent);
56
61
  return;
57
62
  }
58
63
  const topIndex = this.children.indexOf(child);
package/build/tags.d.ts CHANGED
@@ -1 +1,2 @@
1
1
  export declare function nextTag(): number;
2
+ export declare const FIRST_TAG: number;
package/build/tags.js CHANGED
@@ -2,9 +2,39 @@
2
2
  // for root tags). A tag is minted once when a node is first created and then
3
3
  // stays with it across clone-on-write commits (the clone keeps the family), so
4
4
  // this counter only ever moves forward: one tag per node, not per commit.
5
- let next = 2;
5
+ import { dlog } from './debug.js';
6
+ // WHY IT DOES NOT START AT 2. React's own Fabric renderer allocates tags the exact same way and
7
+ // from the exact same seed — `let nextReactTag = 2; ... nextReactTag += 2` in
8
+ // react-native-renderer/src/ReactFiberConfigFabric.js. We replace React as the APP's renderer, but
9
+ // React is still in the bundle and still drives one Fabric surface of its own: **LogBox**. So the
10
+ // first JS error in a symbiote app opens a redbox, React mints tag 2 for it, and Fabric aborts the
11
+ // process on a Create for a tag the engine registered at app start:
12
+ //
13
+ // *** Terminating app due to uncaught exception 'NSInternalInconsistencyException',
14
+ // reason: 'RCTComponentViewRegistry: Attempt to dequeue already registered component.'
15
+ // -[RCTComponentViewRegistry dequeueComponentViewWithComponentHandle:tag:]
16
+ // RCTPerformMountInstructions(...)
17
+ //
18
+ // `RCTComponentViewRegistry` is process-wide, not per-surface, so the two allocators must not
19
+ // overlap. Diagnosed 2026-08-20 on examples/svelte: an `effect_update_depth_exceeded` on the API
20
+ // Playground screen turned into a SIGABRT with no JS stack, because the redbox that would have
21
+ // named the file killed the app instead of showing. Starting a whole surface's worth of tags above
22
+ // React's seed keeps the redbox a redbox: LogBox needs a few hundred nodes, so it can never walk
23
+ // 500k allocations up into this range.
24
+ const REACT_LOGBOX_TAG_RESERVE = 1_000_000;
25
+ let next = REACT_LOGBOX_TAG_RESERVE + 2;
26
+ let announced = false;
6
27
  export function nextTag() {
28
+ if (!announced) {
29
+ announced = true;
30
+ // Printed once, next to the first `commit ... createNode tag=` line: a device log that shows a
31
+ // Fabric tag below this base was minted by React (LogBox), not by us.
32
+ dlog(`tag allocator base=${next} (React's own Fabric tags stay below it)`);
33
+ }
7
34
  const tag = next;
8
35
  next += 2;
9
36
  return tag;
10
37
  }
38
+ // The lowest tag this allocator can ever hand out. Exported for the regression test that pins the
39
+ // no-overlap invariant against React's own allocator; nothing at runtime reads it.
40
+ export const FIRST_TAG = REACT_LOGBOX_TAG_RESERVE + 2;
@@ -21,7 +21,9 @@ export const touchHistory = {
21
21
  mostRecentTimeStamp: 0,
22
22
  };
23
23
  function toFiniteNumber(value) {
24
- return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
24
+ return typeof value === 'number' && Number.isFinite(value)
25
+ ? value
26
+ : undefined;
25
27
  }
26
28
  // Pull a recordable touch out of an untyped entry. RN's getTouchIdentifier throws on
27
29
  // a null id; we skip instead, so events without touch geometry leave the bank untouched.
@@ -35,7 +37,12 @@ function normalizeTouch(raw) {
35
37
  return undefined;
36
38
  if (identifier < 0 || identifier > MAX_TOUCH_BANK)
37
39
  return undefined;
38
- return { identifier, pageX, pageY, timestamp: toFiniteNumber(raw.timestamp) ?? 0 };
40
+ return {
41
+ identifier,
42
+ pageX,
43
+ pageY,
44
+ timestamp: toFiniteNumber(raw.timestamp) ?? 0,
45
+ };
39
46
  }
40
47
  // The changed touches for this frame (start/move/end), defensively read.
41
48
  function changedTouchesOf(nativeEvent) {