@symbiote-native/engine 0.1.7 → 0.2.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 (40) hide show
  1. package/build/alert/index.ios.js +10 -0
  2. package/build/animated/color.d.ts +2 -8
  3. package/build/animated/color.js +4 -45
  4. package/build/animated/easing.js +0 -8
  5. package/build/animated/graph.d.ts +13 -5
  6. package/build/animated/graph.js +85 -11
  7. package/build/animated/index.d.ts +2 -2
  8. package/build/animated/index.js +2 -2
  9. package/build/animated/interpolation.js +1 -1
  10. package/build/animated/leaf-lifecycle.d.ts +7 -0
  11. package/build/animated/leaf-lifecycle.js +185 -0
  12. package/build/animated/native/native-animated.js +1 -0
  13. package/build/animated/rgba.d.ts +8 -0
  14. package/build/animated/rgba.js +55 -0
  15. package/build/animated/value.js +12 -7
  16. package/build/commit.js +1 -5
  17. package/build/debug.d.ts +1 -1
  18. package/build/debug.js +10 -3
  19. package/build/events/index.js +0 -2
  20. package/build/index.d.ts +4 -2
  21. package/build/index.js +2 -1
  22. package/build/layout-animation/index.js +0 -1
  23. package/build/process-font-variant.js +0 -1
  24. package/build/process-transform/index.js +13 -19
  25. package/build/report-error.d.ts +5 -0
  26. package/build/report-error.js +60 -0
  27. package/build/status-bar/index.android.js +5 -6
  28. package/build/style-registry/index.js +95 -16
  29. package/build/style-sheet/index.js +4 -2
  30. package/build/surface.js +2 -1
  31. package/build/vibration/index.android.js +2 -3
  32. package/package.json +1 -1
  33. package/build/animated/interpolation-node.d.ts +0 -15
  34. package/build/animated/interpolation-node.js +0 -58
  35. package/build/style-registry/scope/index.d.ts +0 -3
  36. package/build/style-registry/scope/index.js +0 -41
  37. package/build/touch-history/index.d.ts +0 -23
  38. package/build/touch-history/index.js +0 -150
  39. package/build/type-guards/index.d.ts +0 -4
  40. package/build/type-guards/index.js +0 -15
@@ -119,13 +119,12 @@ export class AnimatedValue extends AnimatedWithChildren {
119
119
  this.offset = offset;
120
120
  }
121
121
  }
122
- // Own the native-driver "start" handshake end-to-end: make this value native, mint
122
+ // Owns the native-driver "start" handshake end-to-end: make this value native, mint
123
123
  // its tag, hand the curve to the native module, and on completion sync the JS value
124
- // (no re-flush to bound leaves' native side; native already moved the view). A
125
- // driver (BaseAnimation.startNativeIfNeeded) supplies only what IT owns: the curve
126
- // config, its own animation id, its platform bag, and its own end callback, never
127
- // touching __makeNative / __getNativeTag / __onNativeUpdate / flushValue directly.
128
- // Information Expert: this value is the one object that actually holds those internals.
124
+ // (no re-flush; native already moved the view). The driver (BaseAnimation.
125
+ // startNativeIfNeeded) supplies only what it owns - curve config, animation id,
126
+ // platform bag, end callback - never touching __makeNative / __getNativeTag /
127
+ // __onNativeUpdate / flushValue directly; those internals live here.
129
128
  __startNativeAnimation(config, nativeId, onEnd, platformConfig) {
130
129
  this.__makeNative(platformConfig);
131
130
  nativeAnimated.startAnimatingNode(nativeId, this.__getNativeTag(), config, result => {
@@ -164,10 +163,16 @@ export class AnimatedValue extends AnimatedWithChildren {
164
163
  this.tracking = null;
165
164
  }
166
165
  }
167
- // Stop any animation and reset to the original value.
166
+ // Stop any animation and reset to the original value. The native graph keeps its OWN copy of
167
+ // the value, so a native-driven node has to be told as well - otherwise the reset lands in JS
168
+ // only and the view stays wherever the animation left it. setValue above pushes for the same
169
+ // reason; RN pushes here too (Libraries/Animated/nodes/AnimatedValue.js:285).
168
170
  resetAnimation(callback) {
169
171
  this.stopAnimation(callback);
170
172
  this.value = this.startingValue;
173
+ if (this.isNative) {
174
+ nativeAnimated.setAnimatedNodeValue(this.__getNativeTag(), this.startingValue);
175
+ }
171
176
  }
172
177
  // Drive this value with an animation. Typically called by Animated.timing /
173
178
  // spring / decay rather than directly.
package/build/commit.js CHANGED
@@ -20,9 +20,7 @@ import { nextTag } from './tags.js';
20
20
  import { registerPostCommit, runPostCommitHooks } from './post-commit.js';
21
21
  import { fabricProps } from './fabric-props.js';
22
22
  import { isRecord } from './type-guards.js';
23
- // processColor/setColorProcessor now live in ./platform-color (the stable color-processing
24
- // leaf every color-touching module imports from); re-exported here so nothing outside this
25
- // module needs to change its import path.
23
+ // Re-exported from ./platform-color so callers don't need to change their import path.
26
24
  export { processColor, setColorProcessor } from './platform-color/index.js';
27
25
  // Per-commit work counters, surfaced via dlog so a device run can prove the
28
26
  // engine is incremental (created=0 with clones after the first mount).
@@ -173,8 +171,6 @@ function reconcile(slot, node, rootTag, hasTextAncestor, renderableParent, force
173
171
  const props = fabricProps(node);
174
172
  const childInText = node.isText || hasTextAncestor;
175
173
  const committed = mirror.get(node);
176
- // The children that actually reach Fabric. Anchors are filtered out here so the
177
- // whole walk (child-set emission, identity diff, mirror) is anchor-blind.
178
174
  const kids = renderableChildren(node);
179
175
  // First mount, or the view kind flipped (RCTText <-> RCTVirtualText when a
180
176
  // <Text> moves in or out of another <Text>): a different native component
package/build/debug.d.ts CHANGED
@@ -2,4 +2,4 @@ declare global {
2
2
  var __SYMBIOTE_DEBUG__: boolean | undefined;
3
3
  }
4
4
  export declare function isDebug(): boolean;
5
- export declare function dlog(message: string): void;
5
+ export declare function dlog(message: string | (() => string)): void;
package/build/debug.js CHANGED
@@ -5,7 +5,13 @@
5
5
  // Metro start (--reset-cache), not a rebuild.
6
6
  // - runtime: globalThis.__SYMBIOTE_DEBUG__ = true, an escape hatch for hosts
7
7
  // where the env isn't reachable.
8
- // Production with neither set pays one property read per call and nothing else.
8
+ // Production with neither set pays one property read per call and nothing else -
9
+ // but ONLY if the caller does not build the message itself first. A template
10
+ // literal is evaluated at the CALL SITE, before dlog can decide anything, so a
11
+ // `dlog(\`… ${JSON.stringify(x)}\`)` on a per-frame path costs its full price with
12
+ // logging off. On a hot path (a getter Angular re-reads every change-detection
13
+ // pass, an Animated reconcile, a scroll-driven apply) pass a THUNK instead:
14
+ // `dlog(() => \`…\`)` - it is only called once the switch is on.
9
15
  function envEnabled() {
10
16
  return typeof process !== 'undefined' && process.env.DEBUG === '1';
11
17
  }
@@ -13,6 +19,7 @@ export function isDebug() {
13
19
  return globalThis.__SYMBIOTE_DEBUG__ === true || envEnabled();
14
20
  }
15
21
  export function dlog(message) {
16
- if (isDebug())
17
- console.log(`[symbiote] ${message}`);
22
+ if (!isDebug())
23
+ return;
24
+ console.log(`[symbiote] ${typeof message === 'function' ? message() : message}`);
18
25
  }
@@ -515,7 +515,6 @@ function bubble(target, listenerName, nativeEvent) {
515
515
  while (node) {
516
516
  const listener = isAnchor(node) ? undefined : node.listeners?.get(listenerName);
517
517
  if (listener) {
518
- // engine owner adds currentTarget + stopPropagation to SymbioteEvent
519
518
  const event = {
520
519
  type: listenerName,
521
520
  target,
@@ -535,7 +534,6 @@ function deliverDirect(target, listenerName, nativeEvent) {
535
534
  const listener = target.listeners?.get(listenerName);
536
535
  if (!listener)
537
536
  return;
538
- // engine owner adds currentTarget + stopPropagation to SymbioteEvent
539
537
  listener({
540
538
  type: listenerName,
541
539
  target,
package/build/index.d.ts CHANGED
@@ -37,11 +37,13 @@ export type { IPlatformStatic, IPlatformOSType, IPlatformSelectSpec } from './pl
37
37
  export type { IPlatformConstantsIOS } from './platform/index.ios';
38
38
  export type { IPlatformConstantsAndroid } from './platform/index.android';
39
39
  export { dlog, isDebug } from './debug';
40
+ export { reportUncaughtError } from './report-error';
41
+ export type { IUncaughtErrorInfo } from './report-error';
40
42
  export { getNativeModule, getEnforcingNativeModule } from './native-modules';
41
43
  export { installDeviceEventHub, NativeEventEmitter, setDeviceEventSource } from './native-events';
42
44
  export type { IEventSubscription, IEventEmitterModule, INativeEventListener, IDeviceEventSource, } from './native-events';
43
- export { AnimatedNode, AnimatedWithChildren, AnimatedValue, AnimatedValueXY, AnimatedColor, AnimatedInterpolation, AnimatedAddition, AnimatedSubtraction, AnimatedMultiplication, AnimatedDivision, AnimatedModulo, AnimatedDiffClamp, AnimatedEvent, add, subtract, multiply, divide, modulo, diffClamp, event, forkEvent, unforkEvent, attachNativeEvent, attachNativeEventHandler, flushValue, Easing, createNumericInterpolation, checkValidRanges, TimingAnimation, SpringAnimation, DecayAnimation, AnimatedTracking, timing, spring, decay, parallel, sequence, stagger, loop, delay, nativeAnimated, isNativeAnimatedAvailable, AnimatedProps, AnimatedStyle, AnimatedTransform, AnimatedMock, reduceProps, isAnimatedNode, readPassthroughStyle, resolveHostNode, } from './animated';
44
- export type { IValueXY, IRgbaValue, IColorInput, IEventConfig, IEventListener, IAnimatedEventHandler, INativeEventAttachment, IValueListener, IEasingFunction, IInterpolationConfig, IExtrapolateType, IAnimation, IEndCallback, IEndResult, ICompositeAnimation, ITimingConfig, ISpringConfig, IDecayConfig, IParallelConfig, ILoopAnimationConfig, INativeNodeConfig, INativeAnimationConfig, INativeEventMapping, IPlatformConfig, } from './animated';
45
+ export { AnimatedNode, AnimatedWithChildren, AnimatedValue, AnimatedValueXY, AnimatedColor, AnimatedInterpolation, AnimatedAddition, AnimatedSubtraction, AnimatedMultiplication, AnimatedDivision, AnimatedModulo, AnimatedDiffClamp, AnimatedEvent, add, subtract, multiply, divide, modulo, diffClamp, event, forkEvent, unforkEvent, attachNativeEvent, attachNativeEventHandler, flushValue, Easing, createNumericInterpolation, checkValidRanges, TimingAnimation, SpringAnimation, DecayAnimation, AnimatedTracking, timing, spring, decay, parallel, sequence, stagger, loop, delay, nativeAnimated, isNativeAnimatedAvailable, AnimatedProps, createAnimatedLeafLifecycle, AnimatedStyle, AnimatedTransform, AnimatedMock, reduceProps, isAnimatedNode, readPassthroughStyle, resolveHostNode, } from './animated';
46
+ export type { IAnimatedLeafLifecycle, IValueXY, IRgbaValue, IColorInput, IEventConfig, IEventListener, IAnimatedEventHandler, INativeEventAttachment, IValueListener, IEasingFunction, IInterpolationConfig, IExtrapolateType, IAnimation, IEndCallback, IEndResult, ICompositeAnimation, ITimingConfig, ISpringConfig, IDecayConfig, IParallelConfig, ILoopAnimationConfig, INativeNodeConfig, INativeAnimationConfig, INativeEventMapping, IPlatformConfig, } from './animated';
45
47
  export { getSlot } from './fabric';
46
48
  export type { IFabricSlot, IFabricNode, IFabricChildSet, IFabricProps, IFabricEventHandler, IRootTag, IMeasureOnSuccess, IMeasureInWindowOnSuccess, IMeasureLayoutOnSuccess, } from './fabric';
47
49
  export { Alert } from './alert';
package/build/index.js CHANGED
@@ -37,9 +37,10 @@ export { registerStyles, resolveClassName, clearGlobalStyles, isClassNameValue,
37
37
  export { scopeClassName } from './style-registry/scope.js';
38
38
  export { Platform } from './platform';
39
39
  export { dlog, isDebug } from './debug.js';
40
+ export { reportUncaughtError } from './report-error.js';
40
41
  export { getNativeModule, getEnforcingNativeModule } from './native-modules/index.js';
41
42
  export { installDeviceEventHub, NativeEventEmitter, setDeviceEventSource } from './native-events.js';
42
- export { AnimatedNode, AnimatedWithChildren, AnimatedValue, AnimatedValueXY, AnimatedColor, AnimatedInterpolation, AnimatedAddition, AnimatedSubtraction, AnimatedMultiplication, AnimatedDivision, AnimatedModulo, AnimatedDiffClamp, AnimatedEvent, add, subtract, multiply, divide, modulo, diffClamp, event, forkEvent, unforkEvent, attachNativeEvent, attachNativeEventHandler, flushValue, Easing, createNumericInterpolation, checkValidRanges, TimingAnimation, SpringAnimation, DecayAnimation, AnimatedTracking, timing, spring, decay, parallel, sequence, stagger, loop, delay, nativeAnimated, isNativeAnimatedAvailable, AnimatedProps, AnimatedStyle, AnimatedTransform, AnimatedMock, reduceProps, isAnimatedNode, readPassthroughStyle, resolveHostNode, } from './animated/index.js';
43
+ export { AnimatedNode, AnimatedWithChildren, AnimatedValue, AnimatedValueXY, AnimatedColor, AnimatedInterpolation, AnimatedAddition, AnimatedSubtraction, AnimatedMultiplication, AnimatedDivision, AnimatedModulo, AnimatedDiffClamp, AnimatedEvent, add, subtract, multiply, divide, modulo, diffClamp, event, forkEvent, unforkEvent, attachNativeEvent, attachNativeEventHandler, flushValue, Easing, createNumericInterpolation, checkValidRanges, TimingAnimation, SpringAnimation, DecayAnimation, AnimatedTracking, timing, spring, decay, parallel, sequence, stagger, loop, delay, nativeAnimated, isNativeAnimatedAvailable, AnimatedProps, createAnimatedLeafLifecycle, AnimatedStyle, AnimatedTransform, AnimatedMock, reduceProps, isAnimatedNode, readPassthroughStyle, resolveHostNode, } from './animated/index.js';
43
44
  export { getSlot } from './fabric.js';
44
45
  // Imperative runtime modules: framework-agnostic native-bridge consumers (no visual, no
45
46
  // lifecycle), moved here from @symbiote-native/react so every adapter re-exports the SAME module.
@@ -148,7 +148,6 @@ function configureNext(config, onAnimationDidEnd, onAnimationDidFail) {
148
148
  onAnimationDidEnd?.();
149
149
  };
150
150
  dlog(`LayoutAnimation.configureNext: dispatching config (duration=${config.duration})`);
151
- // Default to a no-op when the caller doesn't supply one.
152
151
  manager.configureNextLayoutAnimation(config, onComplete, onAnimationDidFail ?? (() => { }));
153
152
  }
154
153
  // ---- the LayoutAnimation facade ------------------------------------------
@@ -12,6 +12,5 @@ export function processFontVariant(fontVariant) {
12
12
  if (typeof fontVariant === 'string') {
13
13
  return fontVariant.split(' ').filter(Boolean);
14
14
  }
15
- // Neither an array nor a string: nothing to split, hand back an empty variant list.
16
15
  return [];
17
16
  }
@@ -1,19 +1,15 @@
1
1
  // JS-side port of RN's processTransform (Libraries/StyleSheet/processTransform.js).
2
- // Same root cause as transformOrigin/boxShadow: ReactNativeStyleAttributes registers
3
- // `transform` with `nativeCSSParsing ? true : {process: processTransform}`, and
4
- // enableNativeCSSParsing() DEFAULTS TO FALSE, so RN's stock path runs processTransform
5
- // IN JS. It only does work for a STRING input (parses the CSS string into the entry
6
- // array); an ARRAY input is returned UNCHANGED. symbiote forwarded a raw string, which
7
- // Android native cast to ReadableArray and crashed with
8
- // `java.lang.String cannot be cast to com.facebook.react.bridge.ReadableArray`. This
9
- // restores the missing JS parse so native always receives the entry array.
2
+ // Same root cause as transformOrigin/boxShadow: RN only parses `transform` in JS when
3
+ // nativeCSSParsing is off (the default), and only for a STRING input - an ARRAY is
4
+ // returned unchanged. symbiote forwarded a raw string once, which Android native cast
5
+ // to ReadableArray and crashed (`String cannot be cast to ReadableArray`). This
6
+ // restores the missing JS parse.
10
7
  //
11
- // CRITICAL: the animated / sticky-header hot path produces transform ARRAYS and flows
12
- // through commit's processValue on every flush, so the array branch MUST return the
13
- // byte-identical reference (no decompose, no clone). RN's only array-path work is
14
- // _validateTransforms, which is __DEV__-only and THROWS via invariant; we never throw
15
- // into the commit path, so we run a non-throwing check that dlogs and still returns the
16
- // array unchanged (matching the boxShadow/transformOrigin idiom).
8
+ // CRITICAL: the animated / sticky-header hot path produces transform ARRAYS on every
9
+ // commit flush, so the array branch MUST return the byte-identical reference (no
10
+ // decompose, no clone). RN's only array-path check, _validateTransforms, is __DEV__-only
11
+ // and throws via invariant; we never throw in commit, so we dlog instead and still
12
+ // return the array unchanged.
17
13
  import { dlog } from '../debug.js';
18
14
  // RN processTransform.js:28: matches each `name(args)` in the CSS string.
19
15
  const TRANSFORM_REGEX = /(\w+)\(([^)]+)\)/g;
@@ -78,16 +74,14 @@ function getKeyAndValueFromCSSTransform(key, args) {
78
74
  return { key, value: isNaN(Number(args)) ? args : Number(args) };
79
75
  }
80
76
  }
81
- // RN processTransform.js:24-50. STRING input is parsed into the entry array; ARRAY input
82
- // is returned UNCHANGED (RN's only array work, _validateTransforms, is __DEV__-only and
83
- // throws; we never throw, so we skip it and run a non-throwing dlog sanity check).
77
+ // RN processTransform.js:24-50. String input is parsed into the entry array; array
78
+ // input is returned unchanged (see file header for why).
84
79
  export function processTransform(transform) {
85
80
  if (transform == null) {
86
81
  return [];
87
82
  }
88
83
  if (typeof transform !== 'string') {
89
- // Hot path: animated / sticky-header transforms arrive here as arrays. Return the
90
- // same reference (no decompose, no clone) so the flush is a no-op.
84
+ // Hot path: return the same reference, no clone (see file header).
91
85
  warnInvalidTransforms(transform);
92
86
  return transform;
93
87
  }
@@ -0,0 +1,5 @@
1
+ export type IUncaughtErrorInfo = {
2
+ readonly origin: string;
3
+ readonly componentStack?: string | null;
4
+ };
5
+ export declare function reportUncaughtError(value: unknown, info: IUncaughtErrorInfo): void;
@@ -0,0 +1,60 @@
1
+ // Hand an error to the host the way React Native itself does, for the seams where an adapter
2
+ // catches something its framework would otherwise have surfaced on its own.
3
+ //
4
+ // RN routes an uncaught render error through ReactFiberErrorDialog -> ExceptionsManager
5
+ // (Libraries/Core/ReactFiberErrorDialog.js), which is reachable only by a deep path into RN's
6
+ // Libraries. `global.ErrorUtils` is the same surface through the documented global that
7
+ // @react-native/js-polyfills installs before any module evaluates - RN's own
8
+ // Libraries/vendor/core/ErrorUtils.js is nothing but `export default global.ErrorUtils`. Off a
9
+ // native host (vitest, node smokes) the global is absent and console.error is the whole channel.
10
+ //
11
+ // This is NOT the dlog channel. dlog is DEBUG-gated and therefore the developer's; an error that
12
+ // blanked the screen has to reach the app whether or not anyone turned diagnostics on.
13
+ // Read off globalThis through Reflect rather than named as an identifier, and deliberately NOT
14
+ // declared in an ambient block: react-native ships its own `const ErrorUtils: ErrorUtils` in
15
+ // src/types/globals.d.ts, so a `declare global { var ErrorUtils }` here redeclares it (TS2451) for
16
+ // every package whose tsconfig pulls in RN's types - the engine's own does not, so it typechecks
17
+ // clean here and breaks ngc downstream. RN's declared shape also lists only the two
18
+ // get/setGlobalHandler members, not reportError, which the polyfill does install.
19
+ function nativeReporter() {
20
+ const utils = Reflect.get(globalThis, 'ErrorUtils');
21
+ if (typeof utils !== 'object' || utils === null)
22
+ return null;
23
+ const report = Reflect.get(utils, 'reportError');
24
+ if (typeof report !== 'function')
25
+ return null;
26
+ return error => {
27
+ Reflect.apply(report, utils, [error]);
28
+ };
29
+ }
30
+ // A throw is not required to be an Error - a string, or null, reaches here just as easily, and
31
+ // the host's reporter wants something with a message and a stack. RN wraps the same way, via
32
+ // SyntheticError.
33
+ function toError(value, origin) {
34
+ if (value instanceof Error)
35
+ return value;
36
+ return new Error(`${origin}: ${String(value)}`);
37
+ }
38
+ export function reportUncaughtError(value, info) {
39
+ const error = toError(value, info.origin);
40
+ if (info.componentStack) {
41
+ // LogBox reads these two off the error to render the component frames; RN's own
42
+ // ReactFiberErrorDialog sets exactly the same pair, in a try/catch because a frozen or
43
+ // sealed error still deserves to be reported, just without the frames.
44
+ try {
45
+ Object.assign(error, { componentStack: info.componentStack, isComponentError: true });
46
+ }
47
+ catch {
48
+ // Reported below regardless.
49
+ }
50
+ }
51
+ const report = nativeReporter();
52
+ if (report !== null) {
53
+ // Exactly one channel, never both: RN routes console.error into LogBox as well, so logging
54
+ // here too would double-report every error. Upstream returns false from showErrorDialog for
55
+ // this same reason (Libraries/Core/ReactFiberErrorDialog.js:57).
56
+ report(error);
57
+ return;
58
+ }
59
+ console.error(`[symbiote] ${info.origin}:`, error);
60
+ }
@@ -4,12 +4,11 @@
4
4
  // setColor/setTranslucent, same module name ('StatusBarManager'). Metro picks this on an
5
5
  // Android host; iOS keeps its own shape. Each adapter wraps these with its declarative component.
6
6
  //
7
- // History: this used to be a no-op. Driving the window flags from our bridgeless surface
8
- // blanked the app: a status-bar relayout triggered stopSurface, which threw "Global was
9
- // not installed" because RN installs global.RN$stopSurface from its own renderer, which we
10
- // replace. Now that render.ts installs RN$stopSurface and tears surfaces down cleanly, the
11
- // relayout survives and the bar updates without blanking (verified on device: show/hide +
12
- // light/dark text). See render.ts's installStopSurfaceGlobal.
7
+ // Driving window flags from our bridgeless surface used to blank the app: a status-bar
8
+ // relayout triggered stopSurface, which threw "Global was not installed" because RN's
9
+ // own renderer installs global.RN$stopSurface and we replace that renderer. Fixed by
10
+ // render.ts installing RN$stopSurface itself (see installStopSurfaceGlobal) - verified
11
+ // on device: show/hide + light/dark text survive the relayout without blanking.
13
12
  import { getNativeModule } from '../native-modules/index.js';
14
13
  import { dlog } from '../debug.js';
15
14
  import { processColor } from '../platform-color/index.js';
@@ -11,11 +11,27 @@
11
11
  // can write EITHER `class="sectionLabel"` OR `class="section-label"` - resolveOne below
12
12
  // falls back to the kebab->camel form on a miss, since authors don't reliably write the
13
13
  // exact camelCase key.
14
- // Compound lookup tries every ordering of 2-4 space-separated class parts joined by
15
- // '.' (e.g. "btn primary" -> "btn.primary" / "primary.btn") before falling back to a
16
- // per-class merge, mirroring CSS compound-selector registration (`.btn.primary { }`).
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
17
  const COMPOUND_MIN_PARTS = 2;
18
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]+$/;
26
+ function splitScopedToken(token) {
27
+ const separator = token.lastIndexOf(SCOPE_SEPARATOR);
28
+ if (separator <= 0)
29
+ 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 };
34
+ }
19
35
  const globalStyles = new Map();
20
36
  // Called by generated code from side-effect style imports. Last import wins on a
21
37
  // name collision, matching CSS cascade behavior.
@@ -49,18 +65,28 @@ export function resolveClassName(className) {
49
65
  const trimmed = className.trim();
50
66
  if (!trimmed)
51
67
  return {};
52
- const exactMatch = globalStyles.get(trimmed) ?? globalStyles.get(kebabToCamel(trimmed));
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);
53
75
  if (exactMatch)
54
76
  return exactMatch;
55
- const parts = trimmed.split(/\s+/).filter(Boolean);
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.
56
84
  if (parts.length >= COMPOUND_MIN_PARTS && parts.length <= COMPOUND_MAX_PARTS) {
57
85
  const compound = tryCompoundLookup(parts);
58
86
  if (compound)
59
- return compound;
87
+ return { ...merged, ...compound };
60
88
  }
61
- return parts.reduce((acc, cls) => {
62
- return { ...acc, ...resolveOne(cls) };
63
- }, {});
89
+ return merged;
64
90
  }
65
91
  function generateCompoundPermutations(parts) {
66
92
  if (parts.length < COMPOUND_MIN_PARTS)
@@ -73,13 +99,13 @@ function generateCompoundPermutations(parts) {
73
99
  }
74
100
  function generateKPermutations(parts, size) {
75
101
  if (size === 0)
76
- return [''];
102
+ return [[]];
77
103
  if (parts.length === 0)
78
104
  return [];
79
105
  const result = [];
80
106
  function helper(current, remaining, depth) {
81
107
  if (depth === size) {
82
- result.push(toCompoundKey(current));
108
+ result.push(current);
83
109
  return;
84
110
  }
85
111
  for (let i = 0; i < remaining.length; i++) {
@@ -113,16 +139,69 @@ export function kebabToCamel(value) {
113
139
  function tryCompoundLookup(parts) {
114
140
  if (parts.length < COMPOUND_MIN_PARTS)
115
141
  return null;
116
- for (const compound of generateCompoundPermutations(parts)) {
117
- const style = globalStyles.get(compound);
118
- if (style)
119
- return style;
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
+ }
120
148
  }
121
149
  return null;
122
150
  }
151
+ function compoundKeysFor(subset) {
152
+ const scoped = scopedCompoundKey(subset);
153
+ return scoped === null ? [toCompoundKey(subset)] : [toCompoundKey(subset), scoped];
154
+ }
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);
181
+ continue;
182
+ }
183
+ if (scope !== undefined && split.scope !== scope)
184
+ return null;
185
+ scope = split.scope;
186
+ bases.push(split.base);
187
+ }
188
+ return scope === undefined ? null : toCompoundKey(bases) + SCOPE_SEPARATOR + scope;
189
+ }
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.
123
195
  function resolveOne(name) {
124
196
  const trimmed = name.trim();
125
197
  if (!trimmed)
126
198
  return {};
127
- return globalStyles.get(trimmed) ?? globalStyles.get(kebabToCamel(trimmed)) ?? {};
199
+ const scoped = lookupKey(trimmed);
200
+ const split = splitScopedToken(trimmed);
201
+ if (split === null)
202
+ return scoped ?? {};
203
+ return { ...lookupKey(split.base), ...scoped };
204
+ }
205
+ function lookupKey(name) {
206
+ return globalStyles.get(name) ?? globalStyles.get(kebabToCamel(name));
128
207
  }
@@ -49,8 +49,10 @@ const absoluteFill = Object.freeze({
49
49
  top: 0,
50
50
  bottom: 0,
51
51
  });
52
- // RN's composeStyles: falsy left → right, falsy right → left, else the pair [a, b]
53
- // (which flatten later collapses, later keys winning).
52
+ // RN's composeStyles: a null/undefined side yields the other, else the pair [a, b]
53
+ // (which flatten later collapses, later keys winning). The test is null/undefined, NOT
54
+ // falsy - compose(0, y) returns [0, y], matching react-native's own
55
+ // src/private/styles/composeStyles.js, which branches on `== null`.
54
56
  function compose(style1, style2) {
55
57
  if (style1 === null || style1 === undefined)
56
58
  return style2;
package/build/surface.js CHANGED
@@ -62,6 +62,7 @@ export class SymbioteSurface {
62
62
  }
63
63
  export function createSurface(rootTag) {
64
64
  installEventHandler();
65
+ const surface = new SymbioteSurface(rootTag);
65
66
  dlog(`surface created root=${rootTag}`);
66
- return new SymbioteSurface(rootTag);
67
+ return surface;
67
68
  }
@@ -1,7 +1,6 @@
1
1
  // Vibration: Android build. Android has a native pattern scheduler, so the whole pattern
2
- // goes to native `vibrateByPattern(pattern, repeat)`: RN encodes "do not repeat" as -1
3
- // and "repeat from start" as 0. Everything else is the shared core. Metro picks this file
4
- // on an Android host.
2
+ // goes to native `vibrateByPattern(pattern, repeat)` (repeat encoding below). Everything
3
+ // else is the shared core. Metro picks this file on an Android host.
5
4
  //
6
5
  // device-verify-pending: the `Vibration` name and the `vibrateByPattern` routing are
7
6
  // confirmed from RN source but not yet exercised on a real Android host: only a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/engine",
3
- "version": "0.1.7",
3
+ "version": "0.2.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": {
@@ -1,15 +0,0 @@
1
- import { AnimatedNode, AnimatedWithChildren } from './graph';
2
- import { type IInterpolationConfig } from './interpolation';
3
- import type { INativeNodeConfig, IPlatformConfig } from './native/native-animated';
4
- export declare class AnimatedInterpolation extends AnimatedWithChildren {
5
- private readonly parent;
6
- private readonly config;
7
- private interpolation;
8
- constructor(parent: AnimatedNode, config: IInterpolationConfig);
9
- private getInterpolation;
10
- __getValue(): number | string;
11
- __attach(): void;
12
- __detach(): void;
13
- __makeNative(platformConfig?: IPlatformConfig): void;
14
- __getNativeConfig(): INativeNodeConfig;
15
- }
@@ -1,58 +0,0 @@
1
- // AnimatedInterpolation: a graph node that maps its parent's numeric value
2
- // through an interpolation. Ported from RN's AnimatedInterpolation.js: numeric,
3
- // string-with-units, and color output ranges (the value graph; native config
4
- // removed). Platform (Native) colors stay out of scope, color.ts defers them.
5
- import { AnimatedNode, AnimatedWithChildren, registerInterpolationFactory } from './graph.js';
6
- import { checkValidRanges, createInterpolation } from './interpolation.js';
7
- export class AnimatedInterpolation extends AnimatedWithChildren {
8
- parent;
9
- config;
10
- interpolation;
11
- constructor(parent, config) {
12
- super();
13
- this.parent = parent;
14
- this.config = config;
15
- // Validate eagerly so a bad range fails at construction, not first frame.
16
- checkValidRanges(config.inputRange, config.outputRange);
17
- }
18
- getInterpolation() {
19
- if (this.interpolation === undefined) {
20
- this.interpolation = createInterpolation(this.config);
21
- }
22
- return this.interpolation;
23
- }
24
- __getValue() {
25
- const parentValue = this.parent.__getValue();
26
- if (typeof parentValue !== 'number') {
27
- throw new Error('Cannot interpolate an input which is not a number');
28
- }
29
- return this.getInterpolation()(parentValue);
30
- }
31
- __attach() {
32
- this.parent.__addChild(this);
33
- super.__attach();
34
- }
35
- __detach() {
36
- this.parent.__removeChild(this);
37
- super.__detach();
38
- }
39
- // Make the upstream value native first, so the parent->interpolation edge can be
40
- // wired when this node is reached from a leaf rather than from the value.
41
- __makeNative(platformConfig) {
42
- this.parent.__makeNative(platformConfig);
43
- super.__makeNative(platformConfig);
44
- }
45
- __getNativeConfig() {
46
- return {
47
- type: 'interpolation',
48
- inputRange: this.config.inputRange,
49
- outputRange: this.config.outputRange,
50
- extrapolateLeft: this.config.extrapolateLeft ?? this.config.extrapolate ?? 'extend',
51
- extrapolateRight: this.config.extrapolateRight ?? this.config.extrapolate ?? 'extend',
52
- };
53
- }
54
- }
55
- // Hand graph.ts's AnimatedNode.interpolate() the real constructor at module load,
56
- // so every node subclass's base-class interpolate() produces an actual
57
- // AnimatedInterpolation without graph.ts ever value-importing this module.
58
- registerInterpolationFactory((parent, config) => new AnimatedInterpolation(parent, config));
@@ -1,3 +0,0 @@
1
- export type IClassToggleMap = Record<string, boolean | undefined>;
2
- export type IScopableClassValue = string | IClassToggleMap | Array<string | IClassToggleMap> | undefined | null;
3
- export declare function scopeClassName(value: IScopableClassValue, localNames: ReadonlySet<string>, scopeId: string): IScopableClassValue;
@@ -1,41 +0,0 @@
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) {
13
- if (value === undefined || value === null)
14
- return value;
15
- if (Array.isArray(value)) {
16
- return value.map(item => scopeClassEntry(item, localNames, scopeId));
17
- }
18
- return scopeClassEntry(value, localNames, scopeId);
19
- }
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) {
29
- if (typeof value === 'object') {
30
- const scoped = {};
31
- for (const [name, enabled] of Object.entries(value)) {
32
- scoped[scopeToken(name, localNames, scopeId)] = enabled;
33
- }
34
- return scoped;
35
- }
36
- return value
37
- .split(/\s+/)
38
- .filter(Boolean)
39
- .map(token => scopeToken(token, localNames, scopeId))
40
- .join(' ');
41
- }