@symbiote-native/engine 0.1.5 → 0.1.6

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.
@@ -17,4 +17,4 @@ export { nativeAnimated, isNativeAnimatedAvailable, type INativeNodeConfig, type
17
17
  export { AnimatedProps } from './props';
18
18
  export { AnimatedStyle, AnimatedTransform } from './style';
19
19
  export { AnimatedMock } from './mock';
20
- export { reduceProps, isAnimatedNode, readPassthroughStyle, resolveHostNode, } from './animated-component-shared';
20
+ export { reduceProps, isAnimatedNode, readPassthroughStyle, resolveHostNode } from './shared';
@@ -25,4 +25,4 @@ export { AnimatedProps } from './props.js';
25
25
  export { AnimatedStyle, AnimatedTransform } from './style.js';
26
26
  export { AnimatedMock } from './mock.js';
27
27
  // Framework-agnostic createAnimatedComponent helpers. Both adapters import them.
28
- export { reduceProps, isAnimatedNode, readPassthroughStyle, resolveHostNode, } from './animated-component-shared.js';
28
+ export { reduceProps, isAnimatedNode, readPassthroughStyle, resolveHostNode } from './shared.js';
package/build/commit.js CHANGED
@@ -13,7 +13,7 @@
13
13
  // references to specific child handles. That bubble is inherent to a persistent
14
14
  // tree and is exactly what React's own Fabric renderer does.
15
15
  import { getSlot, } from './fabric.js';
16
- import { createElement, isAnchor, VIRTUAL_TEXT_COMPONENT } from './node.js';
16
+ import { createElement, debugNodeId, isAnchor, VIRTUAL_TEXT_COMPONENT, } from './node.js';
17
17
  import { dlog, isDebug } from './debug.js';
18
18
  import { flattenStyle } from './style/index.js';
19
19
  import { nextTag } from './tags.js';
@@ -23,7 +23,7 @@ import { isRecord } from './type-guards.js';
23
23
  // processColor/setColorProcessor now live in ./platform-color (the stable color-processing
24
24
  // leaf every color-touching module imports from); re-exported here so nothing outside this
25
25
  // module needs to change its import path.
26
- export { processColor, setColorProcessor } from './platform-color.js';
26
+ export { processColor, setColorProcessor } from './platform-color/index.js';
27
27
  // Per-commit work counters, surfaced via dlog so a device run can prove the
28
28
  // engine is incremental (created=0 with clones after the first mount).
29
29
  const stats = { created: 0, cloneProps: 0, cloneChildren: 0, reused: 0 };
@@ -210,6 +210,13 @@ function reconcile(slot, node, rootTag, hasTextAncestor, renderableParent, force
210
210
  slot.appendChild(handle, reconcile(slot, child, rootTag, childInText, node, true).handle);
211
211
  }
212
212
  logScrollChildren(node, viewName, tag);
213
+ // Investigation instrumentation (search-bar-ref "node not committed" bug): scoped to RNS* so
214
+ // it can be directly compared against the ref-attach log in stack.ts and the dispatch-miss
215
+ // log below — same debugNodeId on both sides proves/disproves an identity mismatch. Kept
216
+ // behind DEBUG per <keep_logs_gate_behind_DEBUG>, never removed.
217
+ if (viewName.startsWith('RNS')) {
218
+ dlog(`mirror.set (create) node=${debugNodeId(node)} tag=${tag} view=${viewName}`);
219
+ }
213
220
  mirror.set(node, {
214
221
  handle,
215
222
  tag,
@@ -259,6 +266,11 @@ function reconcile(slot, node, rootTag, hasTextAncestor, renderableParent, force
259
266
  guardSerializable(propsDiff, viewName, committed.tag);
260
267
  handle = slot.cloneNodeWithNewProps(committed.handle, propsDiff);
261
268
  }
269
+ // Investigation instrumentation (search-bar-ref "node not committed" bug): see the create-path
270
+ // dlog above. Kept behind DEBUG per <keep_logs_gate_behind_DEBUG>, never removed.
271
+ if (viewName.startsWith('RNS')) {
272
+ dlog(`mirror.set (update) node=${debugNodeId(node)} tag=${committed.tag} view=${viewName}`);
273
+ }
262
274
  // The clone keeps the node's family, so its reactTag is unchanged; carry it.
263
275
  mirror.set(node, {
264
276
  handle,
@@ -428,10 +440,12 @@ export function getNativeNode(node) {
428
440
  export function dispatchViewCommand(node, commandName, args) {
429
441
  const record = mirror.get(node);
430
442
  if (record === undefined) {
431
- dlog(`dispatchViewCommand "${commandName}" skipped: node not committed`);
443
+ // node=... compares directly against the mirror.set logs above (same debugNodeId scheme) to
444
+ // prove/disprove a node-identity mismatch — see the search-bar-ref investigation note there.
445
+ dlog(`dispatchViewCommand "${commandName}" skipped: node not committed (node=${debugNodeId(node)} component=${node.component})`);
432
446
  return;
433
447
  }
434
- dlog(`dispatchViewCommand "${commandName}"`);
448
+ dlog(`dispatchViewCommand "${commandName}" (node=${debugNodeId(node)})`);
435
449
  getSlot().dispatchCommand(record.handle, commandName, args);
436
450
  }
437
451
  // Emit an accessibility event (focus/click/viewHoverEnter/windowStateChange) at a node's
@@ -7,7 +7,7 @@
7
7
  import { RAW_TEXT_COMPONENT } from './node.js';
8
8
  import { flattenStyle } from './style/index.js';
9
9
  import { registeredProcessor } from './registry.js';
10
- import { isProcessableColor, processColor } from './platform-color.js';
10
+ import { isProcessableColor, processColor } from './platform-color/index.js';
11
11
  import { processBoxShadow } from './process-box-shadow/index.js';
12
12
  import { processFilter } from './process-filter.js';
13
13
  import { processTransformOrigin } from './process-transform-origin/index.js';
@@ -0,0 +1,24 @@
1
+ import { type IImageSourceProp } from '../image-source-resolver';
2
+ export type IImageSize = {
3
+ width: number;
4
+ height: number;
5
+ };
6
+ export type IImageCacheStatus = 'memory' | 'disk' | 'disk/memory';
7
+ type ISizeSuccess = (width: number, height: number) => void;
8
+ type ISizeFailure = (error: unknown) => void;
9
+ declare function getSize(uri: string, success?: ISizeSuccess, failure?: ISizeFailure): Promise<IImageSize>;
10
+ declare function getSizeWithHeaders(uri: string, headers: Record<string, string>, success?: ISizeSuccess, failure?: ISizeFailure): Promise<IImageSize>;
11
+ declare function prefetch(uri: string, callback?: (requestId: number) => void): Promise<boolean>;
12
+ declare function abortPrefetch(requestId: number): void;
13
+ declare function queryCache(uris: string[]): Promise<Record<string, IImageCacheStatus>>;
14
+ declare function resolveAssetSource(source: IImageSourceProp): unknown;
15
+ export type IImageStatics = {
16
+ getSize: typeof getSize;
17
+ getSizeWithHeaders: typeof getSizeWithHeaders;
18
+ prefetch: typeof prefetch;
19
+ abortPrefetch: typeof abortPrefetch;
20
+ queryCache: typeof queryCache;
21
+ resolveAssetSource: typeof resolveAssetSource;
22
+ };
23
+ export declare const imageStatics: IImageStatics;
24
+ export {};
@@ -0,0 +1,175 @@
1
+ // Image static methods (RN's Image.getSize / prefetch / queryCache / etc).
2
+ //
3
+ // These mirror RN's iOS Image statics (Libraries/Image/Image.ios.js), which delegate to the
4
+ // `ImageLoader` native (Turbo)Module declared in NativeImageLoaderIOS.js. The Android spec
5
+ // (NativeImageLoaderAndroid.js) registers under the SAME module name ('ImageLoader'), so this
6
+ // stays a flat, non-platform-split module - only the Android prefetch call signature differs (a
7
+ // second `requestId` arg), branched on Platform.OS below, not on module name. NOTE the asymmetry
8
+ // in the iOS spec: `getSize` resolves a `[width, height]` ARRAY, while `getSizeWithHeaders`
9
+ // resolves a `{width, height}` OBJECT, both are guarded below before reading. The native result
10
+ // crosses the I/O boundary as `unknown`; we never cast it, we narrow its shape.
11
+ //
12
+ // This is a stateful, native-bridge-touching imperative module (module-level ImageLoader cache +
13
+ // prefetch requestId counter) with no view of its own - it belongs in @symbiote-native/engine
14
+ // alongside Alert/Share, not in a view/render-*.ts file (whose contract is zero state / zero
15
+ // native bridge).
16
+ import { dlog } from '../debug.js';
17
+ import { resolveImageSource } from '../image-source-resolver.js';
18
+ import { getNativeModule } from '../native-modules.js';
19
+ import { Platform } from '../platform';
20
+ import { isNumber } from '../type-guards.js';
21
+ // The iOS native module name RN registers this under (NativeImageLoaderIOS.js resolves
22
+ // `TurboModuleRegistry.getEnforcing<Spec>('ImageLoader')`). A module name like this is only
23
+ // provable on a real host - a headless fake answers to any name - so this iOS name is
24
+ // device-verify-pending.
25
+ const IMAGE_LOADER_MODULE = 'ImageLoader';
26
+ let imageLoaderModule;
27
+ function getImageLoader() {
28
+ if (imageLoaderModule === undefined) {
29
+ imageLoaderModule = getNativeModule(IMAGE_LOADER_MODULE);
30
+ dlog(`Image: ImageLoader module ${imageLoaderModule ? 'resolved' : 'NOT resolved (null)'}`);
31
+ }
32
+ return imageLoaderModule;
33
+ }
34
+ // Narrow native's getSize result. The spec resolves a `[width, height]` array, but tolerate a
35
+ // `{width, height}` object too (getSizeWithHeaders uses that shape).
36
+ function toImageSize(result) {
37
+ if (Array.isArray(result) && isNumber(result[0]) && isNumber(result[1])) {
38
+ return { width: result[0], height: result[1] };
39
+ }
40
+ if (typeof result === 'object' && result !== null) {
41
+ const width = Reflect.get(result, 'width');
42
+ const height = Reflect.get(result, 'height');
43
+ if (isNumber(width) && isNumber(height))
44
+ return { width, height };
45
+ }
46
+ throw new Error(`Image: unexpected size result from native: ${JSON.stringify(result)}`);
47
+ }
48
+ function requireLoader(method) {
49
+ const loader = getImageLoader();
50
+ if (loader === null) {
51
+ throw new Error(`Image.${method}: ImageLoader native module is not available ` +
52
+ '(running headless or not linked on this host).');
53
+ }
54
+ return loader;
55
+ }
56
+ // Resolve image dimensions, optionally via success/failure callbacks. Always returns the Promise
57
+ // too (RN returns void when a callback is passed, but a promise-and-callback shape is friendlier
58
+ // and a strict superset).
59
+ function getSize(uri, success, failure) {
60
+ const promise = Promise.resolve()
61
+ .then(() => requireLoader('getSize').getSize(uri))
62
+ .then(toImageSize);
63
+ if (typeof success === 'function') {
64
+ promise
65
+ .then(size => success(size.width, size.height))
66
+ .catch((error) => {
67
+ if (typeof failure === 'function')
68
+ failure(error);
69
+ else
70
+ dlog(`Image.getSize failed for ${uri}: ${String(error)}`);
71
+ });
72
+ }
73
+ return promise;
74
+ }
75
+ function getSizeWithHeaders(uri, headers, success, failure) {
76
+ const promise = Promise.resolve()
77
+ .then(() => requireLoader('getSizeWithHeaders').getSizeWithHeaders(uri, headers))
78
+ .then(toImageSize);
79
+ if (typeof success === 'function') {
80
+ promise
81
+ .then(size => success(size.width, size.height))
82
+ .catch((error) => {
83
+ if (typeof failure === 'function')
84
+ failure(error);
85
+ else
86
+ dlog(`Image.getSizeWithHeaders failed for ${uri}: ${String(error)}`);
87
+ });
88
+ }
89
+ return promise;
90
+ }
91
+ // Android keys an in-flight prefetch by a monotonic requestId (so abortRequest can cancel it);
92
+ // RN's Image.android.js generates the same way. iOS ignores the arg.
93
+ let prefetchRequestId = 0;
94
+ // Download a remote image into the disk cache. Resolves to whether it succeeded. `callback`
95
+ // receives the requestId (RN's Image.android.js shape) so the caller can later pass it to
96
+ // abortPrefetch.
97
+ async function prefetch(uri, callback) {
98
+ prefetchRequestId += 1;
99
+ const requestId = prefetchRequestId;
100
+ if (typeof callback === 'function')
101
+ callback(requestId);
102
+ const loader = requireLoader('prefetch');
103
+ return (Promise.resolve()
104
+ // Android's prefetchImage keys an abortable request on requestId; iOS takes ONLY the uri and
105
+ // throws on an extra arg (bridgeless TurboModule arg-count check). Match RN's per-platform call.
106
+ .then(() => Platform.OS === 'android'
107
+ ? loader.prefetchImage(uri, requestId)
108
+ : loader.prefetchImage(uri))
109
+ .then(result => result === true)
110
+ .catch((error) => {
111
+ dlog(`Image.prefetch failed for ${uri}: ${String(error)}`);
112
+ throw error;
113
+ }));
114
+ }
115
+ // Cancel an in-flight prefetch by the requestId prefetch handed back. Android only (mirrors
116
+ // Image.android.js -> NativeImageLoaderAndroid.abortRequest); a missing abortRequest (iOS,
117
+ // headless) is a no-op rather than a throw.
118
+ function abortPrefetch(requestId) {
119
+ const loader = getImageLoader();
120
+ if (loader === null || typeof loader.abortRequest !== 'function') {
121
+ dlog(`Image.abortPrefetch(${requestId}): no abortRequest on this host, ignoring`);
122
+ return;
123
+ }
124
+ loader.abortRequest(requestId);
125
+ }
126
+ // Narrow native's queryCache result: an object mapping each known uri to its cache status.
127
+ // Unknown statuses are dropped rather than trusted blindly.
128
+ const CACHE_STATUS = {
129
+ memory: 'memory',
130
+ disk: 'disk',
131
+ 'disk/memory': 'disk/memory',
132
+ };
133
+ function toCacheRecord(result) {
134
+ const record = {};
135
+ if (typeof result !== 'object' || result === null)
136
+ return record;
137
+ for (const key of Object.keys(result)) {
138
+ const value = Reflect.get(result, key);
139
+ if (typeof value === 'string' && Object.hasOwn(CACHE_STATUS, value)) {
140
+ record[key] = CACHE_STATUS[value];
141
+ }
142
+ }
143
+ return record;
144
+ }
145
+ async function queryCache(uris) {
146
+ return Promise.resolve()
147
+ .then(() => {
148
+ const loader = requireLoader('queryCache');
149
+ // The native queryCache never rejects (RCTImageLoader resolves getImageCacheStatus), so a
150
+ // rejection here is a JS/native boundary fault: log whether the method is even callable and
151
+ // the arg shape, to tell "not a function" (interop gap) from a marshalling reject.
152
+ dlog(`Image.queryCache: typeof loader.queryCache=${typeof loader.queryCache} uris=${uris.length}`);
153
+ return loader.queryCache(uris);
154
+ })
155
+ .then(toCacheRecord)
156
+ .catch((error) => {
157
+ dlog(`Image.queryCache failed: ${String(error)}`);
158
+ throw error;
159
+ });
160
+ }
161
+ // PURE JS: run the currently-installed source resolver (the same machinery the Image component
162
+ // uses via resolveImageSource). RN's resolveAssetSource turns a require() asset id into
163
+ // {uri, scale, ...}; the app injects the real one with setImageSourceResolver, and this exposes
164
+ // its output to callers directly.
165
+ function resolveAssetSource(source) {
166
+ return resolveImageSource(source);
167
+ }
168
+ export const imageStatics = {
169
+ getSize,
170
+ getSizeWithHeaders,
171
+ prefetch,
172
+ abortPrefetch,
173
+ queryCache,
174
+ resolveAssetSource,
175
+ };
@@ -0,0 +1,9 @@
1
+ export declare function setImageSourceResolver(resolve: (source: unknown) => unknown): void;
2
+ export declare function resolveImageSource(source: unknown): unknown;
3
+ export type IImageSource = {
4
+ uri?: string;
5
+ scale?: number;
6
+ width?: number;
7
+ height?: number;
8
+ };
9
+ export type IImageSourceProp = IImageSource | IImageSource[] | number;
@@ -0,0 +1,16 @@
1
+ // Image source resolution seam: require('./x.png') asset ids and {uri} sources are resolved by
2
+ // RN's own resolveAssetSource before reaching the shared render fn. The actual resolution is
3
+ // RN-platform-specific, so it is injected here rather than imported, mirroring platform-color.ts's
4
+ // processColor seam - this keeps @symbiote-native/components free of a react-native dependency (and
5
+ // the headless harness working). BOTH the pure renderImage view (@symbiote-native/components) and
6
+ // this package's own image-loader statics (resolveAssetSource) call resolveImageSource; neither
7
+ // reaches into the mutable resolver directly.
8
+ let sourceResolver = source => source;
9
+ export function setImageSourceResolver(resolve) {
10
+ sourceResolver = resolve;
11
+ }
12
+ // Public mirror of RN's Image.resolveAssetSource: run a source through the injected resolver.
13
+ // Headless (no resolver wired) it is the identity, so smokes see the input unchanged.
14
+ export function resolveImageSource(source) {
15
+ return sourceResolver(source);
16
+ }
package/build/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { createElement, createRawText, createAnchor, isAnchor, appendChild, insertBefore, removeChild, setProp, setEventListener, routeProp, getExplicitStyle, setText, isSymbioteNode, isSymbioteEvent, RAW_TEXT_COMPONENT, } from './node';
1
+ export { createElement, createRawText, createAnchor, isAnchor, appendChild, insertBefore, removeChild, setProp, setEventListener, routeProp, getExplicitStyle, setText, isSymbioteNode, isSymbioteEvent, RAW_TEXT_COMPONENT, debugNodeId, } from './node';
2
2
  export { isEventFor } from './view-config';
3
3
  export { registerComponent, setNativeViewConfigSource } from './registry';
4
4
  export { isRecord } from './type-guards';
package/build/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // Every framework adapter drives this tiny mutation API; all Fabric-specific
3
3
  // logic (tag allocation, view-name resolution, clone-on-write, event
4
4
  // normalization) lives behind it, in one place.
5
- export { createElement, createRawText, createAnchor, isAnchor, appendChild, insertBefore, removeChild, setProp, setEventListener, routeProp, getExplicitStyle, setText, isSymbioteNode, isSymbioteEvent, RAW_TEXT_COMPONENT, } from './node.js';
5
+ export { createElement, createRawText, createAnchor, isAnchor, appendChild, insertBefore, removeChild, setProp, setEventListener, routeProp, getExplicitStyle, setText, isSymbioteNode, isSymbioteEvent, RAW_TEXT_COMPONENT, debugNodeId, } from './node.js';
6
6
  export { isEventFor } from './view-config.js';
7
7
  export { registerComponent, setNativeViewConfigSource } from './registry.js';
8
8
  // Real cross-package consumer: core/components' KeyboardAvoidingView render narrows
@@ -21,7 +21,7 @@ export { setColorProcessor, processColor, dispatchViewCommand, sendAccessibility
21
21
  // the Vue renderer's createElement): the imperative measure/setNativeProps/focus API. Lives
22
22
  // here because it depends only on engine internals, so all adapters inherit it identically.
23
23
  export { toPublicInstance } from './host-instance/index.js';
24
- export { PlatformColor, DynamicColorIOS, isOpaqueColorValue } from './platform-color.js';
24
+ export { PlatformColor, DynamicColorIOS, isOpaqueColorValue } from './platform-color/index.js';
25
25
  // CSS-style processors (boxShadow/filter): RN parses these in JS before native because
26
26
  // enableNativeCSSParsing() defaults to false. Exported so an adapter / test can reuse them.
27
27
  export { processBoxShadow } from './process-box-shadow/index.js';
@@ -0,0 +1,18 @@
1
+ import { NativeEventEmitter } from '../native-events';
2
+ declare global {
3
+ var __turboModuleProxy: (<T>(name: string) => T | null) | undefined;
4
+ var nativeModuleProxy: Record<string, unknown> | undefined;
5
+ }
6
+ export declare function getNativeModule<T>(name: string): T | null;
7
+ export declare function getEnforcingNativeModule<T>(name: string): T;
8
+ export interface IDeviceEventModuleConfig<TModule> {
9
+ moduleName: string;
10
+ moduleLogPrefix: string;
11
+ bindModuleToEmitter?: boolean;
12
+ onEmitterCreated?: (emitter: NativeEventEmitter, module: TModule | null) => void;
13
+ }
14
+ export interface IDeviceEventModule<TModule> {
15
+ getModule(): TModule | null;
16
+ getEmitter(): NativeEventEmitter;
17
+ }
18
+ export declare function createDeviceEventModule<TModule>(config: IDeviceEventModuleConfig<TModule>): IDeviceEventModule<TModule>;
@@ -0,0 +1,120 @@
1
+ // JS -> native: reaching a native (Turbo)Module. The New Architecture installs
2
+ // `global.__turboModuleProxy(name)`: a JSI function that returns the native
3
+ // module registered under `name` (a HostObject whose methods call into native),
4
+ // or null. React Native's own `TurboModuleRegistry.get` is just this call; we are
5
+ // one more client of the same global, exactly as `getSlot` is for the view tree.
6
+ //
7
+ // This is first-party access, for symbiote's own modules (StatusBarManager,
8
+ // KeyboardObserver, ...). Third-party RN packages import `TurboModuleRegistry` from
9
+ // `'react-native'` and read the same global themselves; they do not go through
10
+ // here.
11
+ import { dlog } from '../debug.js';
12
+ import { installDeviceEventHub, NativeEventEmitter, } from '../native-events.js';
13
+ import { isRecord } from '../type-guards.js';
14
+ // The native module value crosses from an untyped HostObject into our types here;
15
+ // the caller vouches for its shape via T (the single trust-boundary narrowing, no
16
+ // per-call `as`). Native modules are always non-null objects.
17
+ function isNativeModule(value) {
18
+ return value !== null && value !== undefined;
19
+ }
20
+ // The native module `name`, typed as the caller's interface `T`, or null when no
21
+ // module by that name is registered in the binary (or the proxy is absent, e.g.
22
+ // running headless without a fake installed).
23
+ export function getNativeModule(name) {
24
+ // Non-bridgeless: the function proxy. Call it by name.
25
+ const turboProxy = globalThis.__turboModuleProxy;
26
+ if (typeof turboProxy === 'function') {
27
+ const module = turboProxy(name);
28
+ if (module !== null && module !== undefined)
29
+ return module;
30
+ }
31
+ // Bridgeless: the HostObject proxy, indexed by name. Guard the access: a
32
+ // HostObject may throw for an unlinked name, and a throw here would propagate
33
+ // into a render effect and blank the tree.
34
+ const bridgelessProxy = globalThis.nativeModuleProxy;
35
+ if (bridgelessProxy !== undefined) {
36
+ try {
37
+ const module = bridgelessProxy[name];
38
+ if (isNativeModule(module))
39
+ return module;
40
+ }
41
+ catch (error) {
42
+ dlog(`nativeModuleProxy["${name}"] threw: ${String(error)}`);
43
+ }
44
+ }
45
+ dlog(`native module "${name}" not found ` +
46
+ `(turbo=${typeof turboProxy}, bridgeless=${typeof globalThis.nativeModuleProxy})`);
47
+ return null;
48
+ }
49
+ // Same, but throws when the module is missing, for modules a feature hard-depends
50
+ // on (StatusBar without StatusBarManager cannot function, so failing loud beats a
51
+ // silent no-op).
52
+ export function getEnforcingNativeModule(name) {
53
+ const module = getNativeModule(name);
54
+ if (module === null) {
55
+ throw new Error(`Native module "${name}" is not registered in the binary. ` +
56
+ 'Verify it is linked (New Architecture / bridgeless host with __turboModuleProxy installed).');
57
+ }
58
+ return module;
59
+ }
60
+ // ---- device-event module factory ------------------------------------------
61
+ //
62
+ // AccessibilityInfo (iOS + Android), AppState, Appearance, BackHandler, Keyboard,
63
+ // and Dimensions each hand-rolled the identical plumbing: lazily resolve a native
64
+ // module, lazily build a NativeEventEmitter bound to it, install the device-event
65
+ // hub on first subscribe. `createLinking` (linking/shared.ts) proved this factors
66
+ // out safely for Linking's iOS/Android split; this is the general form for every
67
+ // other lazy-module + emitter pair in the runtime-module layer.
68
+ //
69
+ // Each caller's DEGRADE POLICY stays its own, via config - the factory owns only
70
+ // the plumbing:
71
+ // - `bindModuleToEmitter` (default true): whether the resolved module is wired
72
+ // into the emitter so its addListener/removeListeners observe-counters get
73
+ // pinged. Dimensions' DeviceInfo module has no observe-counters, so it opts out
74
+ // (matching its original `new NativeEventEmitter(undefined)`).
75
+ // - `onEmitterCreated`: runs exactly once, right after the emitter is built -
76
+ // the hook for a caller's own permanent self-subscription (AppState/Appearance/
77
+ // BackHandler/Keyboard each keep a cache fresh or dispatch a chain this way) or
78
+ // one-time hydration from the module's constants (AppState's initial state,
79
+ // Dimensions' initial metrics - the latter also relies on this hook running
80
+ // `addListener` BEFORE the constants read, preserving the original
81
+ // subscribe-before-resolve ordering that guards against missing an update).
82
+ // A structural check, not a cast: narrows an arbitrary resolved module down to
83
+ // IEventEmitterModule only when it actually carries both observe-counter methods.
84
+ // Needed because `TModule` is unconstrained (Dimensions' INativeDeviceInfo doesn't
85
+ // extend IEventEmitterModule at all) - NativeEventEmitter itself re-checks the same
86
+ // shape internally, so this exists to satisfy the type system, not to change
87
+ // behavior.
88
+ function hasEventEmitterShape(value) {
89
+ return (isRecord(value) &&
90
+ typeof value.addListener === 'function' &&
91
+ typeof value.removeListeners === 'function');
92
+ }
93
+ // Build one module's lazy-resolve + lazy-emitter pair. Each call owns its own
94
+ // cache, so two callers (or two platform builds loaded together in one smoke) stay
95
+ // independent - the same guarantee `createLinking` documents.
96
+ export function createDeviceEventModule(config) {
97
+ let module;
98
+ let emitter;
99
+ function getModule() {
100
+ if (module === undefined) {
101
+ module = getNativeModule(config.moduleName);
102
+ dlog(`${config.moduleLogPrefix} ${module ? 'resolved' : 'NOT resolved (null)'}`);
103
+ }
104
+ return module;
105
+ }
106
+ function getEmitter() {
107
+ if (emitter === undefined) {
108
+ // WHY lazy: install on first subscribe so the hub exists before native
109
+ // emits, without a hard bootstrap-order dependency. Idempotent.
110
+ installDeviceEventHub();
111
+ const resolved = getModule();
112
+ const bindModule = config.bindModuleToEmitter ?? true;
113
+ const boundModule = bindModule && resolved !== null && hasEventEmitterShape(resolved) ? resolved : undefined;
114
+ emitter = new NativeEventEmitter(boundModule);
115
+ config.onEmitterCreated?.(emitter, resolved);
116
+ }
117
+ return emitter;
118
+ }
119
+ return { getModule, getEmitter };
120
+ }
package/build/node.d.ts CHANGED
@@ -23,6 +23,7 @@ export interface ISymbioteNode {
23
23
  export declare function createElement(component: string, isText?: boolean): ISymbioteNode;
24
24
  export declare function createRawText(text: string): ISymbioteNode;
25
25
  export declare function isSymbioteNode(value: unknown): value is ISymbioteNode;
26
+ export declare function debugNodeId(node: ISymbioteNode): number;
26
27
  export declare const ANCHOR_COMPONENT = "#anchor";
27
28
  export declare function createAnchor(): ISymbioteNode;
28
29
  export declare function isAnchor(node: ISymbioteNode): boolean;
package/build/node.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // lives here in shared so no adapter re-implements it.
7
7
  import { isEventFor } from './view-config.js';
8
8
  import { isClassNameValue, resolveClassName } from './style-registry/index.js';
9
+ import { dlog } from './debug.js';
9
10
  const BRAND = Symbol('symbiote.node');
10
11
  // A node carries the Fabric view name directly, so adding a primitive (Image,
11
12
  // ScrollView, TextInput) is just a new string from the adapter, no core change.
@@ -52,6 +53,21 @@ export function createRawText(text) {
52
53
  export function isSymbioteNode(value) {
53
54
  return typeof value === 'object' && value !== null && BRAND in value;
54
55
  }
56
+ // Investigation instrumentation (HeaderOptionsScreen search-bar-ref "node not committed" bug):
57
+ // a WeakMap can't be logged, so this gives every node a small human-readable id, assigned lazily
58
+ // on first call — lets a dlog at ref-attach time and a dlog at commit/dispatch time be compared
59
+ // directly to prove whether they're the SAME node object or two different ones. Kept behind
60
+ // DEBUG per <keep_logs_gate_behind_DEBUG>, never removed.
61
+ const debugIds = new WeakMap();
62
+ let nextDebugId = 1;
63
+ export function debugNodeId(node) {
64
+ let id = debugIds.get(node);
65
+ if (id === undefined) {
66
+ id = nextDebugId++;
67
+ debugIds.set(node, id);
68
+ }
69
+ return id;
70
+ }
55
71
  // Vue's runtime-core needs comment/anchor nodes (fragments, v-if, v-for) to track
56
72
  // sibling order; Fabric has no such concept. An anchor is a real retained node so
57
73
  // insert/nextSibling/parentNode ordering stays correct, but the commit walk SKIPS it
@@ -166,7 +182,17 @@ export function routeProp(node, key, value) {
166
182
  }
167
183
  if (ON_PREFIX.test(key)) {
168
184
  const name = listenerName(key);
169
- if (RESPONDER_EVENTS.has(name) || isEventFor(node.component, name)) {
185
+ const isRegisteredEvent = RESPONDER_EVENTS.has(name) || isEventFor(node.component, name);
186
+ // Investigation instrumentation (HeaderOptionsScreen unresponsive-buttons bug): RNS* views
187
+ // derive their events from react-native-screens' own codegen ViewConfig (registry.ts), so an
188
+ // unregistered event silently falls through to setProp below — a dead prop Fabric ignores,
189
+ // indistinguishable from "the button did nothing" at the UI. Scoped to RNS* to avoid noise
190
+ // from the rest of the app. Kept behind DEBUG per <keep_logs_gate_behind_DEBUG>, never removed.
191
+ if (node.component.startsWith('RNS')) {
192
+ dlog(`routeProp: ${node.component} "${key}" -> listener "${name}" ` +
193
+ `registered=${isRegisteredEvent} at t=${Date.now()}`);
194
+ }
195
+ if (isRegisteredEvent) {
170
196
  setEventListener(node, name, value);
171
197
  return;
172
198
  }
@@ -3,7 +3,7 @@
3
3
  // enableNativeCSSParsing(), which DEFAULTS TO FALSE, so RN's stock path parses the CSS gradient
4
4
  // string / structured array in JS and sends only the processed array to native - Fabric's C++
5
5
  // never sees the raw string. This restores that missing JS parse.
6
- import { isOpaqueColorValue, processColor } from '../platform-color.js';
6
+ import { isOpaqueColorValue, processColor } from '../platform-color/index.js';
7
7
  import { dlog } from '../debug.js';
8
8
  import { isRecord } from '../type-guards.js';
9
9
  // RN processBackgroundImage.js: pre-compiled patterns.
@@ -4,7 +4,7 @@
4
4
  // processed array to native; Fabric's C++ never sees the raw string. symbiote was
5
5
  // forwarding the raw value, which native (CSS parsing off) silently ignores: shadow
6
6
  // rendered nothing. This restores the missing JS parse + per-color processColor.
7
- import { isOpaqueColorValue, processColor } from '../platform-color.js';
7
+ import { isOpaqueColorValue, processColor } from '../platform-color/index.js';
8
8
  import { dlog } from '../debug.js';
9
9
  // RN processBoxShadow.js:16-19: split args only on the delimiters that are NOT inside
10
10
  // a parenthesized color like rgba(0,0,0,1).
@@ -4,7 +4,7 @@
4
4
  // structured result. symbiote forwarded the raw value; the array form already worked
5
5
  // (Fabric accepts it raw, `filter:[{brightness:0.5}]` was device-verified), but the
6
6
  // CSS-string form and drop-shadow color processing were missing. This restores them.
7
- import { isOpaqueColorValue, processColor } from './platform-color.js';
7
+ import { isOpaqueColorValue, processColor } from './platform-color/index.js';
8
8
  import { dlog } from './debug.js';
9
9
  import { isRecord } from './type-guards.js';
10
10
  // RN processFilter.js:19-24: pre-compiled patterns.
@@ -12,7 +12,7 @@
12
12
  // light/dark text). See render.ts's installStopSurfaceGlobal.
13
13
  import { getNativeModule } from '../native-modules.js';
14
14
  import { dlog } from '../debug.js';
15
- import { processColor } from '../platform-color.js';
15
+ import { processColor } from '../platform-color/index.js';
16
16
  import { STATUS_BAR_MANAGER } from './shared.js';
17
17
  // processColor returns `unknown` (its result is platform-dependent); narrow to the
18
18
  // number Fabric/native expects, like RN's invariant before setColor. A non-number
@@ -0,0 +1,3 @@
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;
@@ -0,0 +1,41 @@
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
+ }
@@ -0,0 +1,23 @@
1
+ interface ITouchRecord {
2
+ touchActive: boolean;
3
+ startPageX: number;
4
+ startPageY: number;
5
+ startTimeStamp: number;
6
+ currentPageX: number;
7
+ currentPageY: number;
8
+ currentTimeStamp: number;
9
+ previousPageX: number;
10
+ previousPageY: number;
11
+ previousTimeStamp: number;
12
+ }
13
+ interface ITouchHistory {
14
+ touchBank: ITouchRecord[];
15
+ numberActiveTouches: number;
16
+ indexOfSingleActiveTouch: number;
17
+ mostRecentTimeStamp: number;
18
+ }
19
+ export declare const touchHistory: ITouchHistory;
20
+ export declare function recordTouchTrack(kind: 'start' | 'move' | 'end', nativeEvent: Record<string, unknown>): void;
21
+ export declare function resetTouchHistory(): void;
22
+ export declare function attachTouchHistory(nativeEvent: Record<string, unknown>): void;
23
+ export {};
@@ -0,0 +1,150 @@
1
+ // Per-touch position/time tracking, ported from RN's
2
+ // react-native-renderer/.../legacy-events/ResponderTouchHistoryStore.js. PanResponder's
3
+ // multitouch dx/vx math needs each touch's own previous->current delta (RN counts only
4
+ // touches that moved since `_accountsForMovesUpTo`), which a grant-relative centroid of
5
+ // ALL live touches cannot reconstruct. We maintain the bank as touches flow and ATTACH
6
+ // `touchHistory` onto the nativeEvent reaching responder handlers, exactly how
7
+ // ResponderEventPlugin.js sets `*.touchHistory`.
8
+ //
9
+ // events/index.ts consumes only this file's public surface (recordTouchTrack,
10
+ // attachTouchHistory, resetTouchHistory, touchHistory); everything else here is a
11
+ // private implementation detail of the bank.
12
+ import { isRecord } from '../type-guards.js';
13
+ // RN's bank is indexed by touch identifier and warns above 20; we never warn (headless
14
+ // events may carry larger or absent ids), we just skip anything out of a sane range.
15
+ const MAX_TOUCH_BANK = 20;
16
+ const touchBank = [];
17
+ export const touchHistory = {
18
+ touchBank,
19
+ numberActiveTouches: 0,
20
+ indexOfSingleActiveTouch: -1,
21
+ mostRecentTimeStamp: 0,
22
+ };
23
+ function toFiniteNumber(value) {
24
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
25
+ }
26
+ // Pull a recordable touch out of an untyped entry. RN's getTouchIdentifier throws on
27
+ // a null id; we skip instead, so events without touch geometry leave the bank untouched.
28
+ function normalizeTouch(raw) {
29
+ if (!isRecord(raw))
30
+ return undefined;
31
+ const identifier = toFiniteNumber(raw.identifier);
32
+ const pageX = toFiniteNumber(raw.pageX);
33
+ const pageY = toFiniteNumber(raw.pageY);
34
+ if (identifier === undefined || pageX === undefined || pageY === undefined)
35
+ return undefined;
36
+ if (identifier < 0 || identifier > MAX_TOUCH_BANK)
37
+ return undefined;
38
+ return { identifier, pageX, pageY, timestamp: toFiniteNumber(raw.timestamp) ?? 0 };
39
+ }
40
+ // The changed touches for this frame (start/move/end), defensively read.
41
+ function changedTouchesOf(nativeEvent) {
42
+ const raw = nativeEvent.changedTouches;
43
+ if (!Array.isArray(raw))
44
+ return [];
45
+ const out = [];
46
+ for (const entry of raw) {
47
+ const touch = normalizeTouch(entry);
48
+ if (touch !== undefined)
49
+ out.push(touch);
50
+ }
51
+ return out;
52
+ }
53
+ // Count of all touches still down (RN reads nativeEvent.touches.length directly).
54
+ function activeTouchCount(nativeEvent) {
55
+ const raw = nativeEvent.touches;
56
+ return Array.isArray(raw) ? raw.length : 0;
57
+ }
58
+ function recordTouchStart(touch) {
59
+ const record = touchBank[touch.identifier];
60
+ if (record) {
61
+ record.touchActive = true;
62
+ record.startPageX = touch.pageX;
63
+ record.startPageY = touch.pageY;
64
+ record.startTimeStamp = touch.timestamp;
65
+ record.currentPageX = touch.pageX;
66
+ record.currentPageY = touch.pageY;
67
+ record.currentTimeStamp = touch.timestamp;
68
+ record.previousPageX = touch.pageX;
69
+ record.previousPageY = touch.pageY;
70
+ record.previousTimeStamp = touch.timestamp;
71
+ }
72
+ else {
73
+ touchBank[touch.identifier] = {
74
+ touchActive: true,
75
+ startPageX: touch.pageX,
76
+ startPageY: touch.pageY,
77
+ startTimeStamp: touch.timestamp,
78
+ currentPageX: touch.pageX,
79
+ currentPageY: touch.pageY,
80
+ currentTimeStamp: touch.timestamp,
81
+ previousPageX: touch.pageX,
82
+ previousPageY: touch.pageY,
83
+ previousTimeStamp: touch.timestamp,
84
+ };
85
+ }
86
+ touchHistory.mostRecentTimeStamp = touch.timestamp;
87
+ }
88
+ // Move and end share the previous<-current shift; only `touchActive` differs.
89
+ function shiftTouchRecord(touch, active) {
90
+ const record = touchBank[touch.identifier];
91
+ if (!record)
92
+ return;
93
+ record.touchActive = active;
94
+ record.previousPageX = record.currentPageX;
95
+ record.previousPageY = record.currentPageY;
96
+ record.previousTimeStamp = record.currentTimeStamp;
97
+ record.currentPageX = touch.pageX;
98
+ record.currentPageY = touch.pageY;
99
+ record.currentTimeStamp = touch.timestamp;
100
+ touchHistory.mostRecentTimeStamp = touch.timestamp;
101
+ }
102
+ function arrayFirst(value) {
103
+ return Array.isArray(value) ? value[0] : undefined;
104
+ }
105
+ // Maintain the bank as a touch frame flows. Mirrors RN's recordTouchTrack: moveish
106
+ // shifts records, startish records + recomputes numberActiveTouches, endish marks the
107
+ // record inactive + rescans for the single remaining touch. `kind` is the touch phase.
108
+ export function recordTouchTrack(kind, nativeEvent) {
109
+ if (kind === 'move') {
110
+ for (const touch of changedTouchesOf(nativeEvent))
111
+ shiftTouchRecord(touch, true);
112
+ return;
113
+ }
114
+ if (kind === 'start') {
115
+ for (const touch of changedTouchesOf(nativeEvent))
116
+ recordTouchStart(touch);
117
+ touchHistory.numberActiveTouches = activeTouchCount(nativeEvent);
118
+ if (touchHistory.numberActiveTouches === 1) {
119
+ const first = normalizeTouch(arrayFirst(nativeEvent.touches));
120
+ touchHistory.indexOfSingleActiveTouch = first?.identifier ?? -1;
121
+ }
122
+ return;
123
+ }
124
+ for (const touch of changedTouchesOf(nativeEvent))
125
+ shiftTouchRecord(touch, false);
126
+ touchHistory.numberActiveTouches = activeTouchCount(nativeEvent);
127
+ if (touchHistory.numberActiveTouches === 1) {
128
+ for (let i = 0; i < touchBank.length; i++) {
129
+ const record = touchBank[i];
130
+ if (record !== undefined && record.touchActive) {
131
+ touchHistory.indexOfSingleActiveTouch = i;
132
+ break;
133
+ }
134
+ }
135
+ }
136
+ }
137
+ // Drop all touch state. Called on a fully-released / cancelled gesture so a stale bank
138
+ // never leaks geometry into the next gesture's first frame.
139
+ export function resetTouchHistory() {
140
+ touchBank.length = 0;
141
+ touchHistory.numberActiveTouches = 0;
142
+ touchHistory.indexOfSingleActiveTouch = -1;
143
+ touchHistory.mostRecentTimeStamp = 0;
144
+ }
145
+ // Attach the live touch history onto the event the responder handlers receive, matching
146
+ // ResponderEventPlugin.js (`grantEvent.touchHistory = ...`, etc.). PanResponder reads
147
+ // it for the per-touch dx/vx math; handlers that ignore it are unaffected.
148
+ export function attachTouchHistory(nativeEvent) {
149
+ nativeEvent.touchHistory = touchHistory;
150
+ }
@@ -0,0 +1,4 @@
1
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
2
+ export declare function isBoolean(value: unknown): value is boolean;
3
+ export declare function isNumber(value: unknown): value is number;
4
+ export declare function isString(value: unknown): value is string;
@@ -0,0 +1,15 @@
1
+ // Runtime guards to narrow `unknown` at trust boundaries (native payloads, ViewConfig
2
+ // attributes, style values) without an `as` cast. `isRecord` excludes arrays - most
3
+ // call sites mean "a native payload keyed by string," never a list.
4
+ export function isRecord(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+ export function isBoolean(value) {
8
+ return typeof value === 'boolean';
9
+ }
10
+ export function isNumber(value) {
11
+ return typeof value === 'number';
12
+ }
13
+ export function isString(value) {
14
+ return typeof value === 'string';
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/engine",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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
  "repository": {
6
6
  "type": "git",