@symbiote-native/engine 1.2.0 → 1.3.1

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 (110) hide show
  1. package/android/CMakeLists.txt +31 -1
  2. package/android/build.gradle +4 -1
  3. package/build/accessibility-info/index.android.js +21 -17
  4. package/build/accessibility-info/index.ios.js +2 -0
  5. package/build/accessibility-info/shared.d.ts +1 -1
  6. package/build/accessibility-props.d.ts +0 -11
  7. package/build/accessibility-props.js +30 -68
  8. package/build/alert/shared.d.ts +1 -1
  9. package/build/alert/shared.js +3 -7
  10. package/build/animated/graph.js +1 -1
  11. package/build/animated/leaf-lifecycle.js +2 -2
  12. package/build/app-state/index.d.ts +1 -1
  13. package/build/app-state/index.js +35 -27
  14. package/build/asset-source-resolver.d.ts +2 -0
  15. package/build/asset-source-resolver.js +13 -0
  16. package/build/back-handler/index.d.ts +7 -6
  17. package/build/back-handler/index.js +19 -16
  18. package/build/debug.js +8 -22
  19. package/build/dispatch.js +3 -9
  20. package/build/events/index.js +12 -3
  21. package/build/fabric-props.js +75 -180
  22. package/build/fabric.d.ts +2 -8
  23. package/build/fabric.js +27 -33
  24. package/build/host-access.d.ts +1 -128
  25. package/build/host-access.js +96 -205
  26. package/build/host-behavior.d.ts +1 -100
  27. package/build/host-behavior.js +125 -311
  28. package/build/image-loader.d.ts +4 -2
  29. package/build/image-loader.js +24 -48
  30. package/build/image-source-resolver.js +3 -7
  31. package/build/image-source-write.d.ts +1 -12
  32. package/build/image-source-write.js +28 -36
  33. package/build/imperative.d.ts +0 -28
  34. package/build/imperative.js +42 -92
  35. package/build/index.d.ts +3 -2
  36. package/build/index.js +30 -43
  37. package/build/invariant.d.ts +1 -0
  38. package/build/invariant.js +10 -0
  39. package/build/keyboard/index.js +11 -32
  40. package/build/linking/index.android.js +5 -3
  41. package/build/linking/shared.d.ts +1 -1
  42. package/build/linking/shared.js +16 -19
  43. package/build/mutation-buffer.d.ts +0 -177
  44. package/build/mutation-buffer.js +137 -308
  45. package/build/native-engine.d.ts +0 -102
  46. package/build/native-engine.js +38 -98
  47. package/build/native-events.d.ts +5 -0
  48. package/build/native-events.js +30 -18
  49. package/build/native-tree-host.d.ts +0 -21
  50. package/build/native-tree-host.js +14 -31
  51. package/build/node.d.ts +0 -212
  52. package/build/node.js +354 -774
  53. package/build/permissions-android/index.android.d.ts +59 -0
  54. package/build/permissions-android/index.android.js +47 -0
  55. package/build/permissions-android/index.d.ts +1 -115
  56. package/build/permissions-android/index.ios.d.ts +59 -0
  57. package/build/permissions-android/index.ios.js +31 -0
  58. package/build/permissions-android/index.js +3 -184
  59. package/build/permissions-android/shared.d.ts +63 -0
  60. package/build/permissions-android/shared.js +66 -0
  61. package/build/platform/index.android.js +3 -5
  62. package/build/platform/index.ios.js +3 -3
  63. package/build/platform/shared.d.ts +4 -0
  64. package/build/platform/shared.js +9 -0
  65. package/build/platform-color/index.android.d.ts +4 -0
  66. package/build/platform-color/index.android.js +7 -0
  67. package/build/platform-color/index.d.ts +2 -20
  68. package/build/platform-color/index.js +1 -45
  69. package/build/platform-color/shared.d.ts +21 -0
  70. package/build/platform-color/shared.js +41 -0
  71. package/build/post-commit.js +3 -8
  72. package/build/process-aspect-ratio.js +3 -7
  73. package/build/process-background-longhands.js +10 -19
  74. package/build/process-filter.js +11 -19
  75. package/build/process-font-variant.js +3 -7
  76. package/build/registry.d.ts +0 -33
  77. package/build/registry.js +22 -57
  78. package/build/report-error.js +4 -18
  79. package/build/settings/index.android.d.ts +6 -0
  80. package/build/settings/index.android.js +21 -0
  81. package/build/settings/index.d.ts +1 -8
  82. package/build/settings/index.ios.d.ts +8 -0
  83. package/build/settings/index.ios.js +122 -0
  84. package/build/settings/index.js +3 -122
  85. package/build/share/index.android.js +9 -32
  86. package/build/share/index.ios.js +14 -15
  87. package/build/share/shared.d.ts +4 -2
  88. package/build/share/shared.js +7 -10
  89. package/build/status-bar/index.android.js +1 -1
  90. package/build/status-bar/index.ios.js +4 -3
  91. package/build/structured-style.d.ts +0 -9
  92. package/build/structured-style.js +16 -31
  93. package/build/styles.js +3 -6
  94. package/build/surface.d.ts +0 -26
  95. package/build/surface.js +29 -76
  96. package/build/text-input-state.js +4 -8
  97. package/build/toast-android/index.android.d.ts +10 -0
  98. package/build/toast-android/index.android.js +108 -0
  99. package/build/toast-android/index.d.ts +1 -10
  100. package/build/toast-android/index.ios.d.ts +10 -0
  101. package/build/toast-android/index.ios.js +19 -0
  102. package/build/toast-android/index.js +3 -108
  103. package/build/touch-history.js +5 -11
  104. package/build/tree-host.d.ts +0 -270
  105. package/build/tree-host.js +63 -153
  106. package/build/view-config.js +17 -37
  107. package/cpp/SymbioteFabricProps.cpp +204 -120
  108. package/cpp/SymbioteFabricProps.h +5 -0
  109. package/cpp/SymbioteTree.cpp +20 -6
  110. package/package.json +2 -2
@@ -26,6 +26,19 @@ add_library(symbiote_engine
26
26
  # So the JNI shim can say `#include "SymbioteEngineBindings.h"` rather than walk four levels up.
27
27
  target_include_directories(symbiote_engine PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../cpp)
28
28
 
29
+ # `measure` / `measureInWindow` / `measureLayout` need `react/renderer/dom/DOM.h`. libreactnative.so
30
+ # EXPORTS `facebook::react::dom::measure*`, but the prefab ships no `dom/` headers, so without this
31
+ # SymbioteTree.cpp's `__has_include` compiles the throwing stubs. Only that one header is copied into
32
+ # a private root: putting all of ReactCommon on the path would shadow the prefab's own headers.
33
+ set(SYMBIOTE_RN_DOM_HEADER ${REACT_NATIVE_DIR}/ReactCommon/react/renderer/dom/DOM.h)
34
+ if(DEFINED REACT_NATIVE_DIR AND EXISTS ${SYMBIOTE_RN_DOM_HEADER})
35
+ set(SYMBIOTE_RN_DOM_ROOT ${CMAKE_CURRENT_BINARY_DIR}/rn-dom-include)
36
+ file(COPY ${SYMBIOTE_RN_DOM_HEADER} DESTINATION ${SYMBIOTE_RN_DOM_ROOT}/react/renderer/dom)
37
+ target_include_directories(symbiote_engine PRIVATE ${SYMBIOTE_RN_DOM_ROOT})
38
+ else()
39
+ message(WARNING "symbiote_engine: ${SYMBIOTE_RN_DOM_HEADER} not found, measure* will throw")
40
+ endif()
41
+
29
42
  set_target_properties(symbiote_engine PROPERTIES
30
43
  CXX_STANDARD 20
31
44
  CXX_STANDARD_REQUIRED ON
@@ -33,13 +46,30 @@ set_target_properties(symbiote_engine PROPERTIES
33
46
  POSITION_INDEPENDENT_CODE ON
34
47
  )
35
48
 
36
- target_compile_definitions(symbiote_engine PRIVATE -DFOLLY_NO_CONFIG=1)
49
+ # Must match the flags libreactnative.so's folly was built with
50
+ # (ReactAndroid/cmake-utils/folly-flags.cmake). FOLLY_MOBILE is ABI, not tuning: without it arm64
51
+ # picks the SIMD F14 layout while RN's folly is the fallback one, and the link fails on
52
+ # `folly::f14::detail::F14LinkCheck<1>::check()` / `tlsPendingSafeInserts` from any folly::dynamic.
53
+ target_compile_definitions(symbiote_engine PRIVATE
54
+ -DFOLLY_NO_CONFIG=1
55
+ -DFOLLY_HAVE_CLOCK_GETTIME=1
56
+ -DFOLLY_USE_LIBCPP=1
57
+ -DFOLLY_CFG_NO_COROUTINES=1
58
+ -DFOLLY_MOBILE=1
59
+ -DFOLLY_HAVE_RECVMMSG=1
60
+ -DFOLLY_HAVE_PTHREAD=1
61
+ -DFOLLY_HAVE_XSI_STRERROR_R=1
62
+ )
37
63
 
38
64
  # `jsi` and `reactnative` are the only two prefab modules ReactAndroid publishes
39
65
  # (ReactAndroid/build.gradle.kts, the `prefab { }` block — the third is `hermestooling`).
40
66
  # `reactnative` is the merged library that carries react/renderer/uimanager, react/renderer/mounting
41
67
  # and the turbomodule JNI headers our C++ includes; there is no separate
42
68
  # `react_renderer_uimanager` prefab target to link.
69
+ # 16 KB page-size devices (Android 15+) refuse a 4 KB-aligned LOAD segment and drop the whole app
70
+ # into compat mode. NDK r27 still links 4 KB by default; RN's own libraries already ship 16 KB.
71
+ target_link_options(symbiote_engine PRIVATE -Wl,-z,max-page-size=16384)
72
+
43
73
  find_package(ReactAndroid REQUIRED CONFIG)
44
74
  find_package(fbjni REQUIRED CONFIG)
45
75
 
@@ -37,7 +37,10 @@ android {
37
37
 
38
38
  externalNativeBuild {
39
39
  cmake {
40
- arguments "-DANDROID_STL=c++_shared"
40
+ // REACT_NATIVE_DIR: the app's own react-native (the plugin's `reactNativeDir`), so CMake can
41
+ // reach the one ReactCommon header the prefab does not ship (see CMakeLists.txt).
42
+ arguments "-DANDROID_STL=c++_shared",
43
+ "-DREACT_NATIVE_DIR=${project.extensions.getByName('react').reactNativeDir.get().asFile.absolutePath}"
41
44
  }
42
45
  }
43
46
  }
@@ -9,6 +9,7 @@
9
9
  import { createDeviceEventModule } from '../native-modules/index.js';
10
10
  import {} from '../native-events.js';
11
11
  import { dlog } from '../debug.js';
12
+ import { sendAccessibilityEventByTag } from '../fabric.js';
12
13
  import { isBoolean, routeSendAccessibilityEvent, } from './shared.js';
13
14
  // The Android native module name. This is the module the Android JS wrapper
14
15
  // (INativeAccessibilityInfoAndroid) resolves: the stock RN `AccessibilityInfo` Turbo/legacy
@@ -19,6 +20,8 @@ const ACCESSIBILITY_MODULE = 'AccessibilityInfo';
19
20
  // most of them; events with no Android source (iOS-only) are absent and yield an inert
20
21
  // subscription. (RN maps both `change` and `screenReaderChanged` to touchExplorationDidChange.)
21
22
  const ANDROID_DEVICE_EVENT = {
23
+ // RN's deprecated alias (AccessibilityInfo.js EventNames).
24
+ change: 'touchExplorationDidChange',
22
25
  screenReaderChanged: 'touchExplorationDidChange',
23
26
  reduceMotionChanged: 'reduceMotionDidChange',
24
27
  highTextContrastChanged: 'highTextContrastDidChange',
@@ -39,19 +42,18 @@ function getModule() {
39
42
  function getEmitter() {
40
43
  return deviceEventModule.getEmitter();
41
44
  }
42
- // Run a single-callback Android getter as a Promise. Resolves false when the module is
43
- // unlinked OR the optional method is absent on this host; mirrors RN's "missing query ->
44
- // false" contract for the cross-platform getters. The dlog records the miss.
45
- function queryState(pick, label) {
45
+ const MODULE_UNAVAILABLE = 'NativeAccessibilityInfoAndroid is not available';
46
+ // Run a single-callback Android getter as a Promise, REJECTING when it cannot run, as RN does
47
+ // (AccessibilityInfo.js): a required getter names the module, an optional one names itself.
48
+ function queryState(pick, label, isOptional = false) {
46
49
  const module = getModule();
47
- if (module === null) {
48
- dlog(`AccessibilityInfo(android).${label} -> no module (false)`);
49
- return Promise.resolve(false);
50
- }
51
- const getter = pick(module);
52
- if (getter === undefined) {
53
- dlog(`AccessibilityInfo(android).${label} -> method absent (false)`);
54
- return Promise.resolve(false);
50
+ const getter = module === null ? undefined : pick(module);
51
+ if (module === null || getter === undefined) {
52
+ const message = isOptional
53
+ ? `NativeAccessibilityInfoAndroid.${label} is not available`
54
+ : MODULE_UNAVAILABLE;
55
+ dlog(`AccessibilityInfo(android).${label} -> rejected: ${message}`);
56
+ return Promise.reject(new Error(message));
55
57
  }
56
58
  return new Promise(resolve => {
57
59
  getter.call(module, enabled => resolve(enabled));
@@ -70,17 +72,17 @@ class AccessibilityInfoAndroid {
70
72
  return Promise.resolve(false);
71
73
  }
72
74
  isGrayscaleEnabled() {
73
- return queryState(m => m.isGrayscaleEnabled, 'isGrayscaleEnabled');
75
+ return queryState(m => m.isGrayscaleEnabled, 'isGrayscaleEnabled', true);
74
76
  }
75
77
  isInvertColorsEnabled() {
76
- return queryState(m => m.isInvertColorsEnabled, 'isInvertColorsEnabled');
78
+ return queryState(m => m.isInvertColorsEnabled, 'isInvertColorsEnabled', true);
77
79
  }
78
80
  // iOS-only query; resolve false (RN parity).
79
81
  isReduceTransparencyEnabled() {
80
82
  return Promise.resolve(false);
81
83
  }
82
84
  isHighTextContrastEnabled() {
83
- return queryState(m => m.isHighTextContrastEnabled, 'isHighTextContrastEnabled');
85
+ return queryState(m => m.isHighTextContrastEnabled, 'isHighTextContrastEnabled', true);
84
86
  }
85
87
  // iOS-only "Increase Contrast"; Android has no equivalent, so resolve false (RN parity).
86
88
  isDarkerSystemColorsEnabled() {
@@ -91,7 +93,7 @@ class AccessibilityInfoAndroid {
91
93
  return Promise.resolve(false);
92
94
  }
93
95
  isAccessibilityServiceEnabled() {
94
- return queryState(m => m.isAccessibilityServiceEnabled, 'isAccessibilityServiceEnabled');
96
+ return queryState(m => m.isAccessibilityServiceEnabled, 'isAccessibilityServiceEnabled', true);
95
97
  }
96
98
  // Post a string to be announced by the screen reader. No-op without a module.
97
99
  announceForAccessibility(announcement) {
@@ -111,8 +113,10 @@ class AccessibilityInfoAndroid {
111
113
  // bare reactTag can't be resolved back to its SymbioteNode (the mirror is node-keyed), so
112
114
  // this best-effort path is a logged no-op. Callers should use sendAccessibilityEvent(node,
113
115
  // 'focus') with a host ref, which routes a real node through the slot.
116
+ // RN: legacySendAccessibilityEvent(tag, 'focus') -> bridgeless UIManager resolves the tag.
114
117
  setAccessibilityFocus(reactTag) {
115
- dlog(`AccessibilityInfo(android).setAccessibilityFocus(${reactTag}) -> tag-only, no node to route (no-op)`);
118
+ dlog(`AccessibilityInfo(android).setAccessibilityFocus(${reactTag})`);
119
+ sendAccessibilityEventByTag(reactTag, 'focus');
116
120
  }
117
121
  // Recommended UI-change timeout for this user. Resolves the original when the module or
118
122
  // the query is absent (RN parity).
@@ -19,6 +19,8 @@ const ACCESSIBILITY_MODULE = 'AccessibilityManager';
19
19
  // Public event name -> the iOS device event the native side emits. iOS keeps the names
20
20
  // 1:1; the indirection exists only so the mapping stays explicit (Android renames them).
21
21
  const IOS_DEVICE_EVENT = {
22
+ // RN's deprecated alias (AccessibilityInfo.js EventNames).
23
+ change: 'screenReaderChanged',
22
24
  screenReaderChanged: 'screenReaderChanged',
23
25
  reduceMotionChanged: 'reduceMotionChanged',
24
26
  boldTextChanged: 'boldTextChanged',
@@ -1,7 +1,7 @@
1
1
  import { type ISymbioteNode } from '../node';
2
2
  import type { IEventSubscription } from '../native-events';
3
3
  export { isBoolean } from '../type-guards';
4
- export type IAccessibilityChangeEventName = 'screenReaderChanged' | 'reduceMotionChanged' | 'boldTextChanged' | 'grayscaleChanged' | 'invertColorsChanged' | 'reduceTransparencyChanged' | 'darkerSystemColorsChanged' | 'announcementFinished' | 'accessibilityServiceChanged' | 'highTextContrastChanged';
4
+ export type IAccessibilityChangeEventName = 'change' | 'screenReaderChanged' | 'reduceMotionChanged' | 'boldTextChanged' | 'grayscaleChanged' | 'invertColorsChanged' | 'reduceTransparencyChanged' | 'darkerSystemColorsChanged' | 'announcementFinished' | 'accessibilityServiceChanged' | 'highTextContrastChanged';
5
5
  export type IAccessibilityChangeEvent = IAccessibilityChangeEventName;
6
6
  export type IAccessibilityHandle = ISymbioteNode | number | null | undefined;
7
7
  export interface IAccessibilityAnnouncementFinishedEvent {
@@ -1,13 +1,2 @@
1
1
  export declare const ARIA_ALIAS_KEYS: readonly ["role", "aria-label", "aria-labelledby", "aria-live", "aria-hidden", "aria-busy", "aria-checked", "aria-disabled", "aria-expanded", "aria-selected", "aria-modal", "aria-valuemax", "aria-valuemin", "aria-valuenow", "aria-valuetext"];
2
- /**
3
- * Fold the web-alias `aria-*` / `role` props into RN's canonical `accessibility*` props.
4
- *
5
- * Returns the input BY IDENTITY when no alias is present — the fast path that keeps this off the
6
- * hot path for the ~99% of nodes carrying none, and the property idempotence rests on: pass 1
7
- * blanks every alias, so a second pass finds nothing and returns by identity again.
8
- *
9
- * The alias keys are blanked to `undefined` rather than deleted. That is not laziness: `setProp`
10
- * treats an `undefined` write as a delete and `fabricProps` skips undefined, so a blanked alias
11
- * cannot reach Fabric, while a `delete` would deoptimise the object's shape on every folded node.
12
- */
13
2
  export declare function foldAriaProps(props: Record<string, unknown>): Record<string, unknown>;
@@ -1,27 +1,14 @@
1
- // RN's `aria-*` / `role` -> `accessibility*` fold, at the layer every path goes through.
2
- //
3
- // WHY IT IS HERE AND NOT IN A WRAPPER. It used to run inside each primitive's COMPONENT, and a tag
4
- // has none. It also cannot run per attribute — `aria-checked` has to be folded against a sibling
5
- // `accessibilityState` — so it belongs at the one point where the whole bag is known, which is the
6
- // payload build.
7
- //
8
- // IT IS A MOVE, NOT A REWRITE, AND THAT IS DELIBERATE. The function carries TWO CONTRADICTORY
9
- // PRECEDENCE RULES: for the scalars an explicit `accessibility*` WINS and the alias only fills a
10
- // hole (`if (next.X === undefined)`), while INSIDE the `accessibilityState` / `accessibilityValue`
11
- // composites the ALIAS wins per field (`ariaBusy ?? existing?.busy`). Both mirror RN's View.js.
12
- // Anyone "cleaning this up" collapses them into one rule, and every component test stays green
13
- // while real accessibility silently changes. `core/components/src/accessibility-props.test.ts`
14
- // pins both directions; read it before touching the branches below.
15
- //
16
- // Record-level rather than typed, because the engine's caller has a raw `node.props` bag and an
17
- // interface is not assignable to `Record<string, unknown>` (no index signature). The typed
18
- // `resolveAccessibilityProps<T>` in `core/components` stays where adapters already import it and
19
- // delegates here, keeping its own typed gate so the fast path allocates nothing.
1
+ // RN's aria-* / role -> accessibility* fold, at the layer every path goes through: it can't run
2
+ // per attribute (aria-checked folds against a sibling accessibilityState), so it belongs at the
3
+ // one point where the whole bag is known, the payload build.
4
+ // A move, not a rewrite: the function carries two contradictory precedence rules — scalars let an
5
+ // explicit accessibility* win, alias only fills a hole; the composites let the alias win per field
6
+ // instead. Both mirror RN's View.js. accessibility-props.test.ts pins both directions.
7
+ // Record-level rather than typed: the engine's caller has a raw node.props bag with no index
8
+ // signature. The typed resolveAccessibilityProps<T> in core/components delegates here.
20
9
  import { dlog } from './debug.js';
21
- // Copied line for line from the wrapper this replaces. The first copy silently dropped five
22
- // entries (`button`, `grid`, `link`, `list`, `listitem`) — a role that falls through simply passes
23
- // unmapped, so `role="listitem"` would have reached Fabric as `listitem` instead of `list` with
24
- // nothing red anywhere. Diff this against RN's View.js rather than reading it for plausibility.
10
+ // A role that falls through here passes unmapped, reaching Fabric under the wrong name with
11
+ // nothing red anywhere. Diff against RN's View.js, don't just read for plausibility.
25
12
  const ROLE_TO_ACCESSIBILITY_ROLE = {
26
13
  alert: 'alert',
27
14
  button: 'button',
@@ -52,14 +39,11 @@ const ROLE_TO_ACCESSIBILITY_ROLE = {
52
39
  timer: 'timer',
53
40
  toolbar: 'toolbar',
54
41
  };
55
- // Exported so a behavior that folds a DIFFERENT node's bag can name them without restating the
56
- // list. `slotDerived` (host-behavior.ts) takes prop NAMES, so a primitive whose payload derives
57
- // from an owner's aria props has to enumerate them — and a second hand-written copy is exactly what
58
- // `.claude/rules/adapter-parity-audit.md` records going stale one member at a time.
59
- // `as const` rather than `readonly string[]`, so the members are LITERALS. That is what lets a
60
- // consumer index a prop type with them — `pickAccessibilityProps` in the Svelte adapter forwards the
61
- // aria half by looping this list — and it makes the two sides check each other: a name here that is
62
- // not a key of `IAriaProps` stops compiling at the use site rather than going quietly unforwarded.
42
+ // Exported so a behavior folding a different node's bag can name these without restating the
43
+ // list — slotDerived (host-behavior.ts) takes prop names, so a derived primitive enumerates them.
44
+ // as const rather than readonly string[], so the members are literals: pickAccessibilityProps
45
+ // (Svelte adapter) indexes IAriaProps with them, and a name here not a key of IAriaProps then
46
+ // fails to compile at the use site instead of going quietly unforwarded.
63
47
  export const ARIA_ALIAS_KEYS = [
64
48
  'role',
65
49
  'aria-label',
@@ -86,28 +70,17 @@ function hasAnyAriaKey(props) {
86
70
  }
87
71
  return false;
88
72
  }
89
- // `isAriaAliasKey` WAS HERE AND IS GONE (2026-09-18). Its whole reason was to maintain
90
- // `node.hasAriaAlias` from `setProp` — the hottest write path in the engine, 32 001 writes on one
91
- // benchmark create — and that flag existed only to gate the aria fold inside the headless payload
92
- // builder. The fold left for `SymbioteFabricProps.cpp`, which recomputes presence from the bag it
93
- // holds, so the flag became write-only and this function became its only maintainer. Both went.
94
73
  function isRecord(value) {
95
74
  return typeof value === 'object' && value !== null;
96
75
  }
97
76
  function fieldOf(source, field) {
98
77
  return isRecord(source) ? source[field] : undefined;
99
78
  }
100
- /**
101
- * Fold the web-alias `aria-*` / `role` props into RN's canonical `accessibility*` props.
102
- *
103
- * Returns the input BY IDENTITY when no alias is present — the fast path that keeps this off the
104
- * hot path for the ~99% of nodes carrying none, and the property idempotence rests on: pass 1
105
- * blanks every alias, so a second pass finds nothing and returns by identity again.
106
- *
107
- * The alias keys are blanked to `undefined` rather than deleted. That is not laziness: `setProp`
108
- * treats an `undefined` write as a delete and `fabricProps` skips undefined, so a blanked alias
109
- * cannot reach Fabric, while a `delete` would deoptimise the object's shape on every folded node.
110
- */
79
+ // Fold the web-alias aria-*/role props into RN's canonical accessibility* props. Returns the input
80
+ // by identity when no alias is present, keeping this off the hot path for nodes carrying none —
81
+ // and idempotent, since pass 1 blanks every alias so a second pass finds nothing.
82
+ // Alias keys are blanked to undefined rather than deleted: setProp treats undefined as a delete
83
+ // and fabricProps skips it, while a real delete would deoptimise the object's shape.
111
84
  export function foldAriaProps(props) {
112
85
  if (!hasAnyAriaKey(props))
113
86
  return props;
@@ -157,27 +130,16 @@ export function foldAriaProps(props) {
157
130
  if (typeof role === 'string' && bag.accessibilityRole === undefined) {
158
131
  bag.accessibilityRole = ROLE_TO_ACCESSIBILITY_ROLE[role] ?? role;
159
132
  }
160
- // RULE TWO, INSIDE the composites: the polarity INVERTS and the ALIAS wins per field. Read from
161
- // the ORIGINAL props, not from `bag` — the loop above has already blanked the aliases there.
162
- //
163
- // UPSTREAM-BUG(react-native): View.js:96 is `checked: ariaChecked ?? accessibilityState?.checked`
164
- // — NO type coercion. A template writes `aria-checked="true"` as a STRING in every framework we
165
- // support, so `accessibilityState.checked` reaches native as `'true'` where the native side
166
- // declares `boolean | 'mixed'`. Ported verbatim for parity; do NOT add a cast without recording
167
- // a deliberate divergence. Found by the Svelte session 2026-08-31 and pinned as an ASSERTION in
168
- // `adapters/svelte/src/aria-fold-parity.test.ts`, so a future decision to coerce shows up as a
169
- // failing test at the point the decision was made rather than as a silent behaviour change.
170
- //
171
- // A SECOND, SMALLER DIVERGENCE, and this one is ours rather than upstream's — it predates the
172
- // move down and is kept only because changing it here would be an undeclared behaviour change:
173
- // upstream gates the composite on `!= null` (View.js:87-92) and this gates on `!== undefined`.
174
- // So an explicit `aria-busy={null}` builds an all-undefined `accessibilityState` here and builds
175
- // nothing upstream. The VALUES agree either way — `??` treats null and undefined alike — so only
176
- // the composite's existence differs.
177
- //
178
- // The composite is REPLACED by a fresh literal listing exactly the known fields, so an unknown
179
- // field riding on the incoming object is dropped. Faithful to RN and pinned by a test; it is the
180
- // shape of bug that only shows for whoever passes a field RN adds later.
133
+ // Rule two, inside the composites: the polarity inverts and the alias wins per field. Read from
134
+ // the original props, not bag — the loop above has already blanked the aliases there.
135
+ // Upstream bug, ported verbatim: RN's View.js does `checked: ariaChecked ?? accessibilityState
136
+ // ?.checked` with no coercion, so a string "true" reaches native where it declares boolean |
137
+ // 'mixed'. No cast without recording a divergence (aria-fold-parity.test.ts pins it).
138
+ // A second, smaller divergence, ours rather than upstream's: upstream gates the composite on
139
+ // `!= null`, this on `!== undefined`, so `aria-busy={null}` builds an all-undefined
140
+ // accessibilityState here but nothing upstream — the values agree either way.
141
+ // The composite is replaced by a fresh literal listing exactly the known fields, so an unknown
142
+ // field riding on the incoming object is dropped, faithful to RN.
181
143
  const existingState = fieldOf(props, 'accessibilityState');
182
144
  if (existingState !== undefined ||
183
145
  ariaBusy !== undefined ||
@@ -16,4 +16,4 @@ export interface IAlertStatic {
16
16
  alert(title?: string, message?: string, buttons?: IAlertButtons, options?: IAlertOptions): void;
17
17
  }
18
18
  export declare const DEFAULT_POSITIVE_TEXT = "OK";
19
- export declare function normalizeButtons(buttons?: IAlertButtons): IAlertButtons;
19
+ export declare function normalizeButtons(buttons?: IAlertButtons | null): IAlertButtons;
@@ -6,12 +6,8 @@
6
6
  // a factory. No native, no `Platform.OS` read here.
7
7
  // The default positive label RN uses when a button carries no text.
8
8
  export const DEFAULT_POSITIVE_TEXT = 'OK';
9
- // Normalize the `buttons` arg into a consistent list: undefined/empty becomes a single
10
- // default "OK" button, exactly as RN does before handing the list to native. Both
11
- // platform files start from this so the no-buttons case behaves identically.
9
+ // Android's `buttons ? buttons : [{text: 'OK'}]` (Alert.js): only an ABSENT list gets the default
10
+ // OK. An explicit empty array is a dialog with no buttons.
12
11
  export function normalizeButtons(buttons) {
13
- if (buttons === undefined || buttons.length === 0) {
14
- return [{ text: DEFAULT_POSITIVE_TEXT }];
15
- }
16
- return buttons;
12
+ return buttons ?? [{ text: DEFAULT_POSITIVE_TEXT }];
17
13
  }
@@ -278,7 +278,7 @@ export function flushValue(rootNode) {
278
278
  // in RELEASE builds only (dev was fine, every headless test was fine, and the code was even
279
279
  // present in the bundle — it just never ran). Adding a bare `import './interpolation-node'` to
280
280
  // the barrel does NOT fix it either: Babel merges that import with the adjacent re-export of the
281
- // same specifier, and the merged dependency stays lazy. Both were tried on device 2026-08-14.
281
+ // same specifier, and the merged dependency stays lazy. Both were tried on device.
282
282
  //
283
283
  // Keeping the class next to its base class removes the cycle, the factory, and the entire class
284
284
  // of load-order bug with it: `interpolate()` constructs it directly and there is nothing left to
@@ -7,7 +7,7 @@
7
7
  // create-animated-component.tsx, Vue's create-animated-component.ts, Svelte's
8
8
  // animated-props-runtime.ts, Angular's animated-leaf-binder.ts). Each re-derived the same policy,
9
9
  // and they drifted: only Svelte ever grew the rebuild guard below, after a day of device
10
- // debugging in 2026-08. React's `useMemo(..., [rest])` LOOKS like the same guard but is not - its
10
+ // debugging. React's `useMemo(..., [rest])` LOOKS like the same guard but is not - its
11
11
  // dependency is a fresh rest-destructured object every render - and Vue's `pendingLeaf === null`
12
12
  // answers "has render run yet", not "did anything change". A fix landing in one copy is exactly
13
13
  // what <adapters_reach_full_feature_parity> forbids: parity has to be structural.
@@ -86,7 +86,7 @@ export function createAnimatedLeafLifecycle(label) {
86
86
  lastWantsNative = wantsNative;
87
87
  // A SNAPSHOT, never the reference. A caller can legitimately hand back the SAME object on
88
88
  // every update, mutating what its keys resolve to rather than allocating (Svelte's rest
89
- // proxy does exactly that, device-confirmed 2026-08-13); storing it would make the next
89
+ // proxy does exactly that, device-confirmed); storing it would make the next
90
90
  // call run shallowEqualProps on that object against ITSELF - reading the same current values
91
91
  // through both sides - which is unconditionally true. `propsChanged` could then never be
92
92
  // true again and reconcile would be skipped forever, so every rebuilt AnimatedInterpolation
@@ -9,7 +9,7 @@ export type IAppStateStatus = 'inactive' | 'background' | 'active' | 'extension'
9
9
  export type IAppStateEvent = (typeof APP_STATE_EVENT)[keyof typeof APP_STATE_EVENT];
10
10
  declare class AppStateImpl {
11
11
  get isAvailable(): boolean;
12
- get currentState(): string | null;
12
+ get currentState(): string | null | undefined;
13
13
  addEventListener(type: IAppStateEvent, handler: (...args: unknown[]) => void): IEventSubscription;
14
14
  }
15
15
  export declare const AppState: AppStateImpl;
@@ -24,30 +24,39 @@ const APP_STATE_EVENT = {
24
24
  focus: 'focus',
25
25
  blur: 'blur',
26
26
  };
27
- function isStateChangePayload(value) {
28
- return typeof value === 'object' && value !== null && 'app_state' in value;
27
+ // `appStateData.app_state`, read the way RN reads it: no shape check, `undefined` when absent,
28
+ // and a TypeError on a null payload as the property read would throw.
29
+ function appStateOf(payload) {
30
+ if (payload === null || payload === undefined) {
31
+ throw new TypeError(`Cannot read property 'app_state' of ${String(payload)}`);
32
+ }
33
+ const state = Reflect.get(Object(payload), 'app_state');
34
+ return typeof state === 'string' ? state : undefined;
29
35
  }
30
- // Lazily resolved so importing this module has no native side effect: a headless
31
- // run without a fake __turboModuleProxy still loads it; resolution happens on first
32
- // use. `null` when the module isn't linked.
36
+ // `null` until the module resolves (or when it is not linked), as RN's field starts.
33
37
  let currentState = null;
34
- // The self-subscription policy that diverges from a plain lazy-resolve+emitter:
35
- // AppState hydrates `currentState` from the module's initial constants, then keeps it
36
- // fresh forever via a permanent 'appStateDidChange' listener, so a read after a native
37
- // change returns the new value even with nobody else listening (RN parity).
38
+ // AppState.js's constructor, run once when the emitter is built: seed from the constant, keep it
39
+ // fresh from 'appStateDidChange', and ask native for the live state — which wins, and is
40
+ // re-emitted to listeners, unless an event arrived first.
38
41
  const deviceEventModule = createDeviceEventModule({
39
42
  moduleName: APP_STATE_MODULE,
40
43
  moduleLogPrefix: 'AppState: module',
41
44
  onEmitterCreated: (emitter, module) => {
42
- if (module !== null) {
43
- currentState = module.getConstants().initialAppState;
44
- }
45
+ if (module === null)
46
+ return;
47
+ currentState = module.getConstants().initialAppState;
48
+ let eventUpdated = false;
45
49
  emitter.addListener(NATIVE_EVENT.stateDidChange, payload => {
46
- if (!isStateChangePayload(payload))
47
- return;
48
- dlog(`AppState: ${NATIVE_EVENT.stateDidChange} -> ${payload.app_state}`);
49
- currentState = payload.app_state;
50
+ eventUpdated = true;
51
+ currentState = appStateOf(payload);
52
+ dlog(`AppState: ${NATIVE_EVENT.stateDidChange} -> ${String(currentState)}`);
50
53
  });
54
+ module.getCurrentAppState(data => {
55
+ if (!eventUpdated && currentState !== data.app_state) {
56
+ currentState = data.app_state;
57
+ emitter.emit(NATIVE_EVENT.stateDidChange, data);
58
+ }
59
+ }, error => dlog(`AppState.getCurrentAppState failed: ${String(error)}`));
51
60
  },
52
61
  });
53
62
  function getModule() {
@@ -68,33 +77,32 @@ class AppStateImpl {
68
77
  getEmitter();
69
78
  return currentState;
70
79
  }
71
- // Subscribe to an AppState event. Native delivers `appStateDidChange`,
72
- // `memoryWarning`, and `appStateFocusChange`; this maps each onto the requested
73
- // public event. Never throws; a missing module yields a live-but-silent
74
- // subscription (the counters are no-ops without a module).
80
+ // Subscribe to an AppState event, mapped onto the native `appStateDidChange` /
81
+ // `memoryWarning` / `appStateFocusChange`. Throws, as RN does, without a module or for an
82
+ // event it does not know.
75
83
  addEventListener(type, handler) {
84
+ if (getModule() === null) {
85
+ throw new Error('Cannot use AppState when `isAvailable` is false.');
86
+ }
76
87
  const eventEmitter = getEmitter();
77
88
  dlog(`AppState.addEventListener -> ${type}`);
78
89
  switch (type) {
79
90
  case APP_STATE_EVENT.change:
80
- return eventEmitter.addListener(NATIVE_EVENT.stateDidChange, payload => {
81
- if (!isStateChangePayload(payload))
82
- return;
83
- handler(payload.app_state);
84
- });
91
+ return eventEmitter.addListener(NATIVE_EVENT.stateDidChange, payload => handler(appStateOf(payload)));
85
92
  case APP_STATE_EVENT.memoryWarning:
86
93
  return eventEmitter.addListener(NATIVE_EVENT.memoryWarning, () => handler());
87
94
  case APP_STATE_EVENT.focus:
88
95
  return eventEmitter.addListener(NATIVE_EVENT.focusChange, hasFocus => {
89
- if (hasFocus === true)
96
+ if (hasFocus)
90
97
  handler();
91
98
  });
92
99
  case APP_STATE_EVENT.blur:
93
100
  return eventEmitter.addListener(NATIVE_EVENT.focusChange, hasFocus => {
94
- if (hasFocus === false)
101
+ if (!hasFocus)
95
102
  handler();
96
103
  });
97
104
  }
105
+ throw new Error(`Trying to subscribe to unknown event: ${String(type)}`);
98
106
  }
99
107
  }
100
108
  export const AppState = new AppStateImpl();
@@ -0,0 +1,2 @@
1
+ export declare function setAssetSourceResolver(resolve: (source: unknown) => unknown): void;
2
+ export declare function resolveAssetSource(source: unknown): unknown;
@@ -0,0 +1,13 @@
1
+ // Generic asset-id resolution seam, the non-Image sibling of image-source-resolver.ts. Kept as a
2
+ // separate file so a non-image consumer (audio's `number` source, sqlite's `assetSource`) never
3
+ // has to import something named "image" to resolve its own assets.
4
+ let sourceResolver = source => source;
5
+ export function setAssetSourceResolver(resolve) {
6
+ sourceResolver = resolve;
7
+ }
8
+ // Public mirror of image-source-resolver.ts's resolveImageSource, for non-Image consumers of RN's
9
+ // require()'d-asset resolution (audio's `number` source form, sqlite's `assetSource` option).
10
+ // Headless (no resolver wired) it is the identity, so smokes see the input unchanged.
11
+ export function resolveAssetSource(source) {
12
+ return sourceResolver(source);
13
+ }
@@ -1,14 +1,15 @@
1
1
  import { type IEventSubscription } from '../native-events';
2
- declare const BACK_PRESS_EVENT: {
3
- readonly backPress: "backPress";
4
- readonly hardwareBackPress: "hardwareBackPress";
2
+ declare const DEVICE_BACK_EVENT = "hardwareBackPress";
3
+ export type IBackPressEventName = 'backPress' | 'hardwareBackPress';
4
+ export type IHardwareBackPressEvent = {
5
+ readonly type: typeof DEVICE_BACK_EVENT;
6
+ readonly timeStamp: number;
5
7
  };
6
- export type IBackPressEventName = (typeof BACK_PRESS_EVENT)[keyof typeof BACK_PRESS_EVENT];
7
- export type IBackPressHandler = () => boolean | null | undefined | void;
8
+ export type IBackPressHandler = (event: IHardwareBackPressEvent) => boolean | null | undefined | void;
8
9
  declare class BackHandlerImpl {
9
10
  exitApp(): void;
10
11
  addEventListener(_eventName: IBackPressEventName, handler: IBackPressHandler): IEventSubscription;
11
- removeEventListener(_eventName: IBackPressEventName, handler: IBackPressHandler): void;
12
12
  }
13
13
  export declare const BackHandler: BackHandlerImpl;
14
+ export declare function installBackHandler(): void;
14
15
  export {};
@@ -17,12 +17,12 @@ import { dlog } from '../debug.js';
17
17
  const DEVICE_EVENT_MANAGER_MODULE = 'DeviceEventManager';
18
18
  // The device event native emits when the hardware back button is pressed.
19
19
  const DEVICE_BACK_EVENT = 'hardwareBackPress';
20
- // The public event names callers subscribe to. `backPress` is RN's legacy alias
21
- // for `hardwareBackPress`; both map to the same chain.
22
- const BACK_PRESS_EVENT = {
23
- backPress: 'backPress',
24
- hardwareBackPress: 'hardwareBackPress',
25
- };
20
+ function nativeTimeStamp(nativeEvent) {
21
+ if (typeof nativeEvent !== 'object' || nativeEvent === null)
22
+ return undefined;
23
+ const timeStamp = Reflect.get(nativeEvent, 'timeStamp');
24
+ return typeof timeStamp === 'number' ? timeStamp : undefined;
25
+ }
26
26
  // The registry, in registration order. Invoked in reverse on a back press.
27
27
  const backPressSubscriptions = [];
28
28
  function isHandled(result) {
@@ -50,10 +50,14 @@ function getEmitter() {
50
50
  }
51
51
  // Run handlers last-registered-first; the first to return true consumes the press
52
52
  // and the rest are skipped. If none consume it, fall through to the native default.
53
- function dispatchBackPress() {
53
+ function dispatchBackPress(nativeEvent) {
54
+ const event = {
55
+ type: DEVICE_BACK_EVENT,
56
+ timeStamp: nativeTimeStamp(nativeEvent) ?? performance.now(),
57
+ };
54
58
  for (let i = backPressSubscriptions.length - 1; i >= 0; i--) {
55
59
  const handler = backPressSubscriptions[i];
56
- if (handler !== undefined && isHandled(handler())) {
60
+ if (handler !== undefined && isHandled(handler(event))) {
57
61
  dlog(`BackHandler: back press consumed by handler ${i}`);
58
62
  return;
59
63
  }
@@ -90,13 +94,12 @@ class BackHandlerImpl {
90
94
  },
91
95
  };
92
96
  }
93
- // Legacy unsubscribe kept for RN parity. The modern path is the subscription's
94
- // remove() returned by addEventListener.
95
- removeEventListener(_eventName, handler) {
96
- const index = backPressSubscriptions.indexOf(handler);
97
- if (index !== -1) {
98
- backPressSubscriptions.splice(index, 1);
99
- }
100
- }
101
97
  }
102
98
  export const BackHandler = new BackHandlerImpl();
99
+ // Subscribes to `hardwareBackPress` up front, as RN does when BackHandler.android.js loads: Android
100
+ // exits only when JS answers the event with invokeDefaultBackPressHandler, so an app with no handler
101
+ // of its own would otherwise have a dead back button. Called by the host bootstrap, not at module
102
+ // load — a barrel-reached module's load-time side effect does not run in a release bundle.
103
+ export function installBackHandler() {
104
+ getEmitter();
105
+ }