@symbiote-native/engine 0.5.0 → 1.0.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 (91) hide show
  1. package/README.md +39 -14
  2. package/android/CMakeLists.txt +51 -0
  3. package/android/build.gradle +90 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/cpp/SymbioteEngineJni.cpp +72 -0
  6. package/android/src/main/java/dev/symbiotenative/engine/SymbioteEngineModule.kt +43 -0
  7. package/android/src/main/java/dev/symbiotenative/engine/SymbioteEnginePackage.kt +35 -0
  8. package/build/accessibility-info/shared.js +1 -1
  9. package/build/accessibility-props.d.ts +1 -8
  10. package/build/accessibility-props.js +13 -16
  11. package/build/animated/animations/composition.d.ts +1 -1
  12. package/build/animated/animations/composition.js +18 -4
  13. package/build/animated/easing.d.ts +3 -2
  14. package/build/animated/easing.js +17 -88
  15. package/build/animated/event.js +6 -1
  16. package/build/animated/host-binding.d.ts +1 -1
  17. package/build/animated/host-binding.js +19 -4
  18. package/build/animated/index.d.ts +1 -1
  19. package/build/animated/mock.d.ts +1 -19
  20. package/build/animated/props.js +1 -1
  21. package/build/animated/rgba.js +16 -50
  22. package/build/events/index.js +88 -40
  23. package/build/fabric-props.d.ts +1 -1
  24. package/build/fabric-props.js +116 -184
  25. package/build/fabric.d.ts +9 -0
  26. package/build/fabric.js +32 -0
  27. package/build/host-access.d.ts +125 -0
  28. package/build/host-access.js +280 -0
  29. package/build/host-behavior.d.ts +84 -21
  30. package/build/host-behavior.js +196 -30
  31. package/build/image-source-write.d.ts +16 -0
  32. package/build/image-source-write.js +65 -0
  33. package/build/imperative.d.ts +49 -0
  34. package/build/imperative.js +258 -0
  35. package/build/index.d.ts +14 -7
  36. package/build/index.js +53 -10
  37. package/build/mutation-buffer.d.ts +222 -0
  38. package/build/mutation-buffer.js +491 -0
  39. package/build/native-engine.d.ts +182 -0
  40. package/build/native-engine.js +178 -0
  41. package/build/native-tree-host.d.ts +25 -0
  42. package/build/native-tree-host.js +66 -0
  43. package/build/node.d.ts +172 -57
  44. package/build/node.js +839 -383
  45. package/build/pan-responder/index.js +27 -52
  46. package/build/platform-color/index.d.ts +1 -1
  47. package/build/platform-color/index.js +11 -4
  48. package/build/process-background-image/index.js +30 -566
  49. package/build/process-background-longhands.d.ts +4 -0
  50. package/build/process-background-longhands.js +44 -0
  51. package/build/process-box-shadow/index.js +23 -187
  52. package/build/process-filter.js +27 -300
  53. package/build/process-transform/index.d.ts +1 -1
  54. package/build/process-transform/index.js +25 -107
  55. package/build/process-transform-origin/index.d.ts +1 -1
  56. package/build/process-transform-origin/index.js +29 -102
  57. package/build/registry.d.ts +36 -0
  58. package/build/registry.js +73 -0
  59. package/build/sound-manager/index.d.ts +3 -0
  60. package/build/sound-manager/index.js +36 -0
  61. package/build/structured-style.d.ts +10 -0
  62. package/build/structured-style.js +180 -0
  63. package/build/style-registry/index.d.ts +14 -0
  64. package/build/style-registry/index.js +60 -11
  65. package/build/surface.d.ts +31 -2
  66. package/build/surface.js +138 -56
  67. package/build/text-input-state.d.ts +1 -0
  68. package/build/text-input-state.js +17 -3
  69. package/build/tree-host.d.ts +307 -0
  70. package/build/tree-host.js +211 -0
  71. package/build/view-config.js +4 -4
  72. package/codegen-specs/NativeSymbioteEngine.ts +27 -0
  73. package/cpp/SymbioteDebug.cpp +51 -0
  74. package/cpp/SymbioteDebug.h +54 -0
  75. package/cpp/SymbioteEngineBindings.cpp +232 -0
  76. package/cpp/SymbioteEngineBindings.h +59 -0
  77. package/cpp/SymbioteFabricProps.cpp +2619 -0
  78. package/cpp/SymbioteFabricProps.h +223 -0
  79. package/cpp/SymbioteTree.cpp +2478 -0
  80. package/cpp/SymbioteTree.h +257 -0
  81. package/ios/SymbioteEngineModule.h +25 -0
  82. package/ios/SymbioteEngineModule.mm +44 -0
  83. package/package.json +31 -3
  84. package/react-native.config.cjs +23 -0
  85. package/symbiote-engine.podspec +42 -0
  86. package/build/animated/bezier.d.ts +0 -1
  87. package/build/animated/bezier.js +0 -102
  88. package/build/commit.d.ts +0 -49
  89. package/build/commit.js +0 -1058
  90. package/build/tags.d.ts +0 -2
  91. package/build/tags.js +0 -40
@@ -0,0 +1,2619 @@
1
+ #include "SymbioteFabricProps.h"
2
+
3
+ #include "SymbioteDebug.h"
4
+
5
+ #include <algorithm>
6
+ #include <array>
7
+ #include <cctype>
8
+ #include <cmath>
9
+ #include <cstdint>
10
+ #include <cstdlib>
11
+ #include <cstring>
12
+ #include <optional>
13
+ #include <string>
14
+ #include <string_view>
15
+ #include <unordered_map>
16
+ #include <unordered_set>
17
+ #include <variant>
18
+ #include <vector>
19
+
20
+ #include <react/renderer/css/CSSColor.h>
21
+ #include <react/renderer/css/CSSValueParser.h>
22
+
23
+ namespace symbiote {
24
+
25
+ using folly::dynamic;
26
+
27
+ namespace {
28
+
29
+ // ── WHAT THIS MIRRORS AND WHAT IT DELIBERATELY DOES NOT ──────────────────────────────────────────
30
+ //
31
+ // `core/engine/src/fabric-props.ts` is the reference. Ported here: the raw-text short circuit, the
32
+ // aria/role fold, the component-keyed `value` -> `text` fold, the behavior's own fold (CALLED, in
33
+ // JS — see `IPayloadFold` and `foldFor` in `SymbioteTree.cpp`), the top-level walk, and the style
34
+ // hoist. Two things are NOT, and each is a decision rather than a gap:
35
+ //
36
+ // the ViewConfig-derived processor registry (`registeredProcessor`) — populated in JS from RN's
37
+ // `ReactNativeViewConfigRegistry` at first use, per third-party component. Our own primitives are
38
+ // never in it (`resolve()` short-circuits every builtin to EMPTY), so omitting it costs only a
39
+ // community view's own `validAttributes[*].process`, and the colour half of that is covered by
40
+ // `kColorProps` below wherever the names coincide.
41
+ //
42
+ // the structured CSS style processors (boxShadow / filter / transform / transformOrigin /
43
+ // aspectRatio / fontVariant / experimental_backgroundImage). NOT a gap any more, and no longer
44
+ // something to port: they run at WRITE time in `core/engine/src/structured-style.ts`, so the value
45
+ // in `node.props` is already structured whichever builder reads it. They were resolved in the
46
+ // reference only for a while, which meant they were resolved HEADLESS only — a CSS-string
47
+ // `experimental_backgroundImage` reached Fabric as a string and was dropped in silence, since
48
+ // `enableNativeCSSParsing()` is false. Porting them here would have been a second implementation
49
+ // of seven parsers; moving them one step earlier costs none.
50
+ //
51
+ // ── THE TWO INPUTS THE REFERENCE READS OFF THE NODE, AND WHERE THEY WENT ─────────────────────────
52
+ //
53
+ // `node.component` crosses already, as the `OP_CREATE_ELEMENT` string and `OP_SET_COMPONENT`.
54
+ // `node.hasAriaAlias` is RECOMPUTED — see `hasAriaAlias` below. It is a memo, not a fact.
55
+ //
56
+ // ── TWO THINGS THE WIRE ALREADY DECIDED, SO THEY NEED NO CODE HERE ───────────────────────────────
57
+ //
58
+ // The reference skips function-valued props. A function cannot be in this bag at all:
59
+ // `routeProp` diverts every `on*` callback to `setEventListener`, and `jsi::dynamicFromValue`
60
+ // throws on one regardless. The reference also skips `undefined`-valued props; `recordSetProp`
61
+ // encodes `undefined` as `NO_VALUE`, which ERASES the key, so no such value ever arrives.
62
+
63
+ constexpr const char *kRawTextComponent = "RCTRawText";
64
+ // RN's two Text defaults live on THIS component and not on raw text — see `applyTextDefaults`.
65
+ constexpr const char *kTextComponent = "RCTText";
66
+ // Android's switch is its OWN Fabric component with its own prop names (`on`/`enabled` rather than
67
+ // `value`/`disabled`), which is what lets `foldSwitchProps` branch on the name instead of `#ifdef`.
68
+ constexpr const char *kAndroidSwitchComponent = "AndroidSwitch";
69
+ constexpr const char *kSinglelineTextInput = "RCTSinglelineTextInputView";
70
+ constexpr const char *kMultilineTextInput = "RCTMultilineTextInputView";
71
+
72
+ // Colour props must reach Fabric as platform ints. `fromRawValueShared.h` parses a CSS STRING only
73
+ // when `enableNativeCSSParsing()` is on, and it defaults to FALSE — an unparsed string falls
74
+ // through to `parsePlatformColor`, which looks for a SEMANTIC name and quietly yields nothing for
75
+ // `#ff0000`. Copied name for name from the reference's COLOR_PROPS; a name added there and not here
76
+ // is a colour that silently stops painting on device only.
77
+ //
78
+ // This list covers OUR OWN primitives, whose style colours reach no ViewConfig — `resolve()`
79
+ // short-circuits every builtin to EMPTY. A third-party view's colour props are NOT its business and
80
+ // must never be added here: those are declared by the view's own `validAttributes[*].process`, and
81
+ // `configPayloadFold` applies them in JS before this file ever sees the bag.
82
+ const std::unordered_set<std::string> kColorProps = {
83
+ "backgroundColor",
84
+ "color",
85
+ "borderColor",
86
+ "borderTopColor",
87
+ "borderRightColor",
88
+ "borderBottomColor",
89
+ "borderLeftColor",
90
+ "borderStartColor",
91
+ "borderEndColor",
92
+ "borderBlockColor",
93
+ "borderBlockStartColor",
94
+ "borderBlockEndColor",
95
+ "shadowColor",
96
+ "textShadowColor",
97
+ "overlayColor",
98
+ "outlineColor",
99
+ "tintColor",
100
+ "placeholderTextColor",
101
+ "selectionColor",
102
+ "cursorColor",
103
+ "underlineColorAndroid",
104
+ "textDecorationColor",
105
+ "selectionHandleColor",
106
+ "onTintColor",
107
+ "thumbTintColor",
108
+ "trackColorForTrue",
109
+ "trackColorForFalse",
110
+ "trackTintColor",
111
+ };
112
+
113
+ // ── COLOUR ───────────────────────────────────────────────────────────────────────────────────────
114
+
115
+ std::optional<int> hexDigit(char character) {
116
+ if (character >= '0' && character <= '9') return character - '0';
117
+ if (character >= 'a' && character <= 'f') return character - 'a' + 10;
118
+ if (character >= 'A' && character <= 'F') return character - 'A' + 10;
119
+ return std::nullopt;
120
+ }
121
+
122
+ std::optional<uint32_t> parseHexColor(std::string_view text) {
123
+ std::vector<int> digits;
124
+ digits.reserve(text.size());
125
+ for (char character : text) {
126
+ auto digit = hexDigit(character);
127
+ if (!digit.has_value()) return std::nullopt;
128
+ digits.push_back(*digit);
129
+ }
130
+ auto pair = [&](size_t at) { return static_cast<uint32_t>(digits[at] * 16 + digits[at + 1]); };
131
+ auto twice = [&](size_t at) { return static_cast<uint32_t>(digits[at] * 17); };
132
+ switch (digits.size()) {
133
+ case 3:
134
+ return 0xFF000000u | (twice(0) << 16) | (twice(1) << 8) | twice(2);
135
+ case 4:
136
+ return (twice(3) << 24) | (twice(0) << 16) | (twice(1) << 8) | twice(2);
137
+ case 6:
138
+ return 0xFF000000u | (pair(0) << 16) | (pair(2) << 8) | pair(4);
139
+ case 8:
140
+ return (pair(6) << 24) | (pair(0) << 16) | (pair(2) << 8) | pair(4);
141
+ default:
142
+ return std::nullopt;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Every spelling the hex fast path above does not claim: `rgb()` and `rgba()` in both the
148
+ * comma and space forms, `hsl()`, `hwb()`, `color()`, and the named colours — `transparent`
149
+ * among them, which is why this file no longer special-cases it.
150
+ *
151
+ * This is React Native's OWN colour grammar, the one `fromRawValueShared.h` reaches for when
152
+ * `enableNativeCSSParsing()` is on. It is header-only out of `React-renderercss`, which
153
+ * `install_modules_dependencies` already puts on this pod, so it costs a dependency we have and
154
+ * spares the build a second hand-rolled CSS parser to keep in step with the first.
155
+ *
156
+ * The ceiling this replaces was real and cost a day on device: a chip list coloured with
157
+ * `hsl(...)` passed through as a STRING, and `PlatformColorParser.mm` answers a string with
158
+ * `clearColor()` — so every chip painted transparent, on all five adapters at once, while every
159
+ * headless test stayed green because the JS applier stores a prop value and never converts it.
160
+ */
161
+ std::optional<uint32_t> parseCssColor(const std::string &text) {
162
+ using facebook::react::CSSColor;
163
+ const auto parsed = facebook::react::parseCSSProperty<CSSColor>(text);
164
+ if (!std::holds_alternative<CSSColor>(parsed)) return std::nullopt;
165
+ const CSSColor color = std::get<CSSColor>(parsed);
166
+ return (static_cast<uint32_t>(color.a) << 24) | (static_cast<uint32_t>(color.r) << 16) |
167
+ (static_cast<uint32_t>(color.g) << 8) | static_cast<uint32_t>(color.b);
168
+ }
169
+
170
+ /**
171
+ * The colour half of the reference's `processValue`, minus the injected `processColor`.
172
+ *
173
+ * WHAT PASSES THROUGH UNTOUCHED, AND WHY THAT IS THE SAFE DIRECTION. A number is already a platform
174
+ * int. An OPAQUE colour (`PlatformColor` / `DynamicColorIOS`, an object carrying `semantic` or
175
+ * `dynamic`) is exactly what `PlatformColorParser.mm` reads out of a `RawValue`, so handing it over
176
+ * verbatim is more faithful than routing it through JS. A string the grammar cannot parse is not a
177
+ * colour at all, and passing it on leaves the platform to say so.
178
+ *
179
+ * Hex keeps its own parser rather than going through the tokenizer: it is the spelling nearly every
180
+ * style uses, this runs per colour key per node per commit, and it is twenty lines.
181
+ */
182
+ dynamic processColorValue(const dynamic &value) {
183
+ if (!value.isString()) return value;
184
+ const std::string &text = value.getString();
185
+ const std::optional<uint32_t> parsed = (!text.empty() && text[0] == '#')
186
+ ? parseHexColor(std::string_view(text).substr(1))
187
+ : parseCssColor(text);
188
+ if (!parsed.has_value()) return value;
189
+ // Widened rather than reinterpreted: RN's `processColor` yields an UNSIGNED 32-bit ARGB, and
190
+ // `fromRawValueShared` reads it back as `(int64_t)value` before shifting. A signed narrowing here
191
+ // would make every colour with alpha 0x80 or above negative.
192
+ return dynamic(static_cast<int64_t>(*parsed));
193
+ }
194
+
195
+ dynamic processValue(const std::string &key, const dynamic &value) {
196
+ if (kColorProps.count(key) == 0) return value;
197
+ return processColorValue(value);
198
+ }
199
+
200
+ // ── STYLE ────────────────────────────────────────────────────────────────────────────────────────
201
+
202
+ /**
203
+ * Hoist one style slot's keys into the payload being built, recursing on POSITION only — the same
204
+ * rule `flattenStyle` follows, and for the same reason: `transform: [{translateX: 5}]` is an
205
+ * array-VALUED prop, not a nested style.
206
+ *
207
+ * There is no intermediate object, which is the shape React Native itself uses
208
+ * (`ReactNativeAttributePayload.addNestedProperty`); its `flattenStyle` appears only on the UPDATE
209
+ * path. The reference's identity-keyed memo has no counterpart here and cannot have one: the bag is
210
+ * a `folly::dynamic` copied off the JSI value, so a style object shared by a thousand rows arrives
211
+ * as a thousand distinct values. The work that memo saved is a per-key copy plus, for a colour, one
212
+ * parse — bounded by style keys per node, not by the tree.
213
+ *
214
+ * Later entries win, because a later write overwrites the same key.
215
+ *
216
+ * A NULL-valued style key ERASES instead. The reference distinguishes `undefined` (erase, so a
217
+ * later entry can clear an earlier one) from `null` (write, meaning reset to the default), and this
218
+ * side cannot: `jsi::dynamicFromValue` collapses both to null. Erasing is the resolvable half — it
219
+ * keeps the documented clearing behaviour, and it is output-equivalent for the `null` case anyway,
220
+ * since an absent key and an explicit null both leave Fabric on the default in a create payload,
221
+ * while `diffProps` re-sends the vanished key as an explicit null on the clone path.
222
+ */
223
+ void addStyle(dynamic &out, const dynamic &style) {
224
+ if (style.isArray()) {
225
+ for (const auto &entry : style) addStyle(out, entry);
226
+ return;
227
+ }
228
+ if (!style.isObject()) return;
229
+ for (const auto &pair : style.items()) {
230
+ if (!pair.first.isString()) continue;
231
+ const std::string &key = pair.first.getString();
232
+ if (pair.second.isNull()) {
233
+ out.erase(key);
234
+ continue;
235
+ }
236
+ out[key] = processValue(key, pair.second);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * The last value a (possibly nested) style slot holds for `key`, or null.
242
+ *
243
+ * LAST rather than first: `addStyle` writes in order and a later entry wins, so this has to agree
244
+ * with what the hoist will do or the two disagree about what the style says. Used only by the image
245
+ * rule, which accepts `resizeMode` and `tintColor` as style keys as well as props.
246
+ */
247
+ const dynamic *lastStyleValue(const dynamic &style, const char *key) {
248
+ if (style.isArray()) {
249
+ const dynamic *found = nullptr;
250
+ for (const auto &entry : style) {
251
+ const dynamic *inner = lastStyleValue(entry, key);
252
+ if (inner != nullptr) found = inner;
253
+ }
254
+ return found;
255
+ }
256
+ if (!style.isObject()) return nullptr;
257
+ return style.get_ptr(key);
258
+ }
259
+
260
+ // ── ARIA ─────────────────────────────────────────────────────────────────────────────────────────
261
+
262
+ // Copied line for line from `core/engine/src/accessibility-props.ts`, which was itself copied from
263
+ // RN's View.js. A role that falls through passes UNMAPPED, so a missing entry is silent: diff this
264
+ // against the reference rather than reading it for plausibility.
265
+ const std::unordered_map<std::string, std::string> kRoleToAccessibilityRole = {
266
+ {"alert", "alert"},
267
+ {"button", "button"},
268
+ {"checkbox", "checkbox"},
269
+ {"combobox", "combobox"},
270
+ {"grid", "grid"},
271
+ {"heading", "header"},
272
+ {"img", "image"},
273
+ {"link", "link"},
274
+ {"list", "list"},
275
+ {"listitem", "list"},
276
+ {"menu", "menu"},
277
+ {"menubar", "menubar"},
278
+ {"menuitem", "menuitem"},
279
+ {"none", "none"},
280
+ {"presentation", "none"},
281
+ {"progressbar", "progressbar"},
282
+ {"radio", "radio"},
283
+ {"radiogroup", "radiogroup"},
284
+ {"scrollbar", "scrollbar"},
285
+ {"searchbox", "search"},
286
+ {"slider", "adjustable"},
287
+ {"spinbutton", "spinbutton"},
288
+ {"summary", "summary"},
289
+ {"switch", "switch"},
290
+ {"tab", "tab"},
291
+ {"tablist", "tablist"},
292
+ {"timer", "timer"},
293
+ {"toolbar", "toolbar"},
294
+ };
295
+
296
+ const std::vector<std::string> kAriaKeys = {
297
+ "role",
298
+ "aria-label",
299
+ "aria-labelledby",
300
+ "aria-live",
301
+ "aria-hidden",
302
+ "aria-busy",
303
+ "aria-checked",
304
+ "aria-disabled",
305
+ "aria-expanded",
306
+ "aria-selected",
307
+ "aria-modal",
308
+ "aria-valuemax",
309
+ "aria-valuemin",
310
+ "aria-valuenow",
311
+ "aria-valuetext",
312
+ };
313
+
314
+ bool isAriaAliasKey(const std::string &key) {
315
+ return key == "role" || key.rfind("aria-", 0) == 0;
316
+ }
317
+
318
+ /**
319
+ * The gate `ISymbioteNode.hasAriaAlias` is on the other side of the wire.
320
+ *
321
+ * It is a MEMO, not a fact: it is sticky, and `foldAriaProps` re-checks presence itself, so the
322
+ * reference's output is the fold applied whenever the bag holds an alias and the flag only decides
323
+ * whether the probe runs. That makes it recomputable here, exactly, and nothing has to cross.
324
+ *
325
+ * One pass over the keys the bag HAS rather than the reference's 15 probes for keys it usually has
326
+ * not — the bag is in hand on this side, which is the whole difference. `undefined` cannot be a
327
+ * value here (`recordSetProp` erases the key instead), so key presence is the same question the
328
+ * reference's `props[key] !== undefined` asks.
329
+ */
330
+ bool hasAriaAlias(const dynamic &props) {
331
+ for (const auto &pair : props.items()) {
332
+ if (pair.first.isString() && isAriaAliasKey(pair.first.getString())) return true;
333
+ }
334
+ return false;
335
+ }
336
+
337
+ const dynamic *fieldOf(const dynamic *source, const char *field) {
338
+ if (source == nullptr || !source->isObject()) return nullptr;
339
+ return source->get_ptr(field);
340
+ }
341
+
342
+ /** `a ?? b` — a null holds no value, and an absent key is the same as a null one here. */
343
+ dynamic coalesce(const dynamic *first, const dynamic *second) {
344
+ if (first != nullptr && !first->isNull()) return *first;
345
+ if (second != nullptr && !second->isNull()) return *second;
346
+ return dynamic(nullptr);
347
+ }
348
+
349
+ // `aria-labelledby` is a comma-separated list. The reference splits on /\s*,\s*/ and keeps empty
350
+ // pieces, so a trailing comma yields a trailing empty string on both sides.
351
+ dynamic splitLabelledBy(const std::string &text) {
352
+ dynamic out = dynamic::array();
353
+ size_t at = 0;
354
+ while (true) {
355
+ const size_t comma = text.find(',', at);
356
+ const size_t end = comma == std::string::npos ? text.size() : comma;
357
+ size_t begin = at;
358
+ size_t stop = end;
359
+ while (begin < stop && std::isspace(static_cast<unsigned char>(text[begin]))) begin += 1;
360
+ while (stop > begin && std::isspace(static_cast<unsigned char>(text[stop - 1]))) stop -= 1;
361
+ out.push_back(text.substr(begin, stop - begin));
362
+ if (comma == std::string::npos) return out;
363
+ at = comma + 1;
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Fold the web-alias `aria-*` / `role` props into RN's canonical `accessibility*` props.
369
+ *
370
+ * A MOVE of `accessibility-props.ts`, not a rewrite, and its TWO CONTRADICTORY PRECEDENCE RULES
371
+ * survive intact: for the scalars an explicit `accessibility*` WINS and the alias only fills a hole,
372
+ * while INSIDE the `accessibilityState` / `accessibilityValue` composites the ALIAS wins per field.
373
+ * Both mirror RN's View.js. Collapsing them into one rule changes real accessibility with nothing
374
+ * visible on screen — read `core/components/src/accessibility-props.test.ts` before touching either
375
+ * branch, including the UPSTREAM-BUG(react-native) note there about `aria-checked` reaching native
376
+ * as a STRING, which is ported deliberately.
377
+ *
378
+ * Aliases are ERASED rather than blanked. The reference writes `undefined` over them so its own
379
+ * top-level walk skips them; the payload is the same either way, and this side has no `undefined`.
380
+ */
381
+ dynamic foldAriaProps(const dynamic &props) {
382
+ dynamic bag = props;
383
+
384
+ const dynamic *role = props.get_ptr("role");
385
+ const dynamic *ariaLabel = props.get_ptr("aria-label");
386
+ const dynamic *ariaLabelledBy = props.get_ptr("aria-labelledby");
387
+ const dynamic *ariaLive = props.get_ptr("aria-live");
388
+ const dynamic *ariaHidden = props.get_ptr("aria-hidden");
389
+ const dynamic *ariaBusy = props.get_ptr("aria-busy");
390
+ const dynamic *ariaChecked = props.get_ptr("aria-checked");
391
+ const dynamic *ariaDisabled = props.get_ptr("aria-disabled");
392
+ const dynamic *ariaExpanded = props.get_ptr("aria-expanded");
393
+ const dynamic *ariaSelected = props.get_ptr("aria-selected");
394
+ const dynamic *ariaModal = props.get_ptr("aria-modal");
395
+ const dynamic *ariaValueMax = props.get_ptr("aria-valuemax");
396
+ const dynamic *ariaValueMin = props.get_ptr("aria-valuemin");
397
+ const dynamic *ariaValueNow = props.get_ptr("aria-valuenow");
398
+ const dynamic *ariaValueText = props.get_ptr("aria-valuetext");
399
+
400
+ for (const auto &key : kAriaKeys) bag.erase(key);
401
+
402
+ // RULE ONE, for every scalar: the explicit prop WINS, the alias only fills a hole.
403
+ if (ariaLabelledBy != nullptr && ariaLabelledBy->isString() &&
404
+ bag.get_ptr("accessibilityLabelledBy") == nullptr) {
405
+ bag["accessibilityLabelledBy"] = splitLabelledBy(ariaLabelledBy->getString());
406
+ }
407
+
408
+ if (ariaLabel != nullptr && bag.get_ptr("accessibilityLabel") == nullptr) {
409
+ bag["accessibilityLabel"] = *ariaLabel;
410
+ }
411
+
412
+ if (ariaLive != nullptr && bag.get_ptr("accessibilityLiveRegion") == nullptr) {
413
+ const bool isOff = ariaLive->isString() && ariaLive->getString() == "off";
414
+ bag["accessibilityLiveRegion"] = isOff ? dynamic("none") : *ariaLive;
415
+ }
416
+
417
+ // One input, TWO outputs, and the second is conditional on the VALUE rather than on presence.
418
+ if (ariaHidden != nullptr) {
419
+ if (bag.get_ptr("accessibilityElementsHidden") == nullptr) {
420
+ bag["accessibilityElementsHidden"] = *ariaHidden;
421
+ }
422
+ if (ariaHidden->isBool() && ariaHidden->getBool() &&
423
+ bag.get_ptr("importantForAccessibility") == nullptr) {
424
+ bag["importantForAccessibility"] = "no-hide-descendants";
425
+ }
426
+ }
427
+
428
+ if (ariaModal != nullptr && bag.get_ptr("accessibilityViewIsModal") == nullptr) {
429
+ bag["accessibilityViewIsModal"] = *ariaModal;
430
+ }
431
+
432
+ if (role != nullptr && role->isString() && bag.get_ptr("accessibilityRole") == nullptr) {
433
+ const auto mapped = kRoleToAccessibilityRole.find(role->getString());
434
+ bag["accessibilityRole"] =
435
+ mapped == kRoleToAccessibilityRole.end() ? role->getString() : mapped->second;
436
+ }
437
+
438
+ // RULE TWO, INSIDE the composites: the polarity INVERTS and the ALIAS wins per field. Read from
439
+ // the ORIGINAL props — the erase loop above has already taken the aliases out of `bag`.
440
+ //
441
+ // The composite is REPLACED by a fresh object listing exactly the known fields, so an unknown
442
+ // field riding on the incoming object is dropped. Faithful to RN, and pinned by a test.
443
+ const dynamic *existingState = props.get_ptr("accessibilityState");
444
+ if (existingState != nullptr || ariaBusy != nullptr || ariaChecked != nullptr ||
445
+ ariaDisabled != nullptr || ariaExpanded != nullptr || ariaSelected != nullptr) {
446
+ dynamic state = dynamic::object();
447
+ state["busy"] = coalesce(ariaBusy, fieldOf(existingState, "busy"));
448
+ state["checked"] = coalesce(ariaChecked, fieldOf(existingState, "checked"));
449
+ state["disabled"] = coalesce(ariaDisabled, fieldOf(existingState, "disabled"));
450
+ state["expanded"] = coalesce(ariaExpanded, fieldOf(existingState, "expanded"));
451
+ state["selected"] = coalesce(ariaSelected, fieldOf(existingState, "selected"));
452
+ bag["accessibilityState"] = std::move(state);
453
+ }
454
+
455
+ const dynamic *existingValue = props.get_ptr("accessibilityValue");
456
+ if (existingValue != nullptr || ariaValueMax != nullptr || ariaValueMin != nullptr ||
457
+ ariaValueNow != nullptr || ariaValueText != nullptr) {
458
+ dynamic value = dynamic::object();
459
+ value["max"] = coalesce(ariaValueMax, fieldOf(existingValue, "max"));
460
+ value["min"] = coalesce(ariaValueMin, fieldOf(existingValue, "min"));
461
+ value["now"] = coalesce(ariaValueNow, fieldOf(existingValue, "now"));
462
+ value["text"] = coalesce(ariaValueText, fieldOf(existingValue, "text"));
463
+ bag["accessibilityValue"] = std::move(value);
464
+ }
465
+
466
+ return bag;
467
+ }
468
+
469
+ // ── TEXT INPUT ───────────────────────────────────────────────────────────────────────────────────
470
+
471
+ /**
472
+ * RN HAS NO `value` FABRIC PROP. A TextInput's controlled value rides as the private `text` prop,
473
+ * and the wrapper is where `value ?? defaultValue` used to be folded — so a LOWERED element sent a
474
+ * key no ViewConfig declares and the field rendered EMPTY, with nothing red anywhere.
475
+ *
476
+ * GATED ON THE COMPONENT, NOT ON THE PROP: `value` is also a prop of Switch and Slider, and a fold
477
+ * keyed on the prop name would write a bogus `text` onto both.
478
+ *
479
+ * An explicit `text` is left alone — that is the component path, where the wrapper already folded,
480
+ * and re-folding would let a stale `value` overwrite what it computed.
481
+ *
482
+ * The caller checks that there is anything to fold, so this never copies for nothing.
483
+ */
484
+ dynamic foldTextInputValue(const dynamic &props) {
485
+ const dynamic *value = props.get_ptr("value");
486
+ const dynamic *defaultValue = props.get_ptr("defaultValue");
487
+
488
+ dynamic folded = props;
489
+ if (folded.get_ptr("text") == nullptr) {
490
+ folded["text"] = value != nullptr ? *value : *defaultValue;
491
+ }
492
+ folded.erase("value");
493
+ folded.erase("defaultValue");
494
+ return folded;
495
+ }
496
+
497
+ // ── TEXT INPUT: THE WEB SPELLING, RESOLVED ───────────────────────────────────────────────────────
498
+ //
499
+ // What Blink does for `<input>`, done here for the same reason: an app writes the W3C name and the
500
+ // platform knows only its own. `inputMode` -> `keyboardType`, `enterKeyHint` -> `returnKeyType`,
501
+ // `readOnly` -> `editable`, one `autoComplete` token -> Android's `autoComplete` AND iOS's
502
+ // `textContentType`. It is a property of React Native, not of any app or framework, so every adapter
503
+ // gets it for the price of emitting the tag.
504
+ //
505
+ // MOVED FROM `core/components/src/behaviors/text-input.ts`, where it was a `payloadFold` — a JS
506
+ // closure the walk called per node per commit, converting the whole props bag out and back for it.
507
+ // Measured at the entire gap between React's walk and every other adapter's on one tree:
508
+ // `foldsFound=1000`, ~17 us apiece. There is NO TypeScript twin of what follows, deliberately; the
509
+ // contract is `core/engine/cpp/tests/js/text-input-payload.itest.ts`, which reads the payload this
510
+ // builder actually sent.
511
+ //
512
+ // Every table below is RN's own, cited to the line of `Libraries/Components/TextInput/TextInput.js`.
513
+
514
+ /** RN's inputMode -> keyboardType map, TextInput.js:815. `search` is split per platform, below. */
515
+ const std::unordered_map<std::string, std::string> &inputModeToKeyboardType() {
516
+ static const auto *table = new std::unordered_map<std::string, std::string>{
517
+ {"decimal", "decimal-pad"}, {"email", "email-address"}, {"none", "default"},
518
+ {"numeric", "number-pad"}, {"tel", "phone-pad"}, {"text", "default"},
519
+ {"url", "url"},
520
+ };
521
+ return *table;
522
+ }
523
+
524
+ /** RN's enterKeyHint -> returnKeyType map, TextInput.js:805. Note `enter` -> 'default'. */
525
+ const std::unordered_map<std::string, std::string> &enterKeyHintToReturnKeyType() {
526
+ static const auto *table = new std::unordered_map<std::string, std::string>{
527
+ {"done", "done"}, {"enter", "default"}, {"go", "go"}, {"next", "next"},
528
+ {"previous", "previous"}, {"search", "search"}, {"send", "send"},
529
+ };
530
+ return *table;
531
+ }
532
+
533
+ /** RN's W3C autocomplete -> Android `autoComplete` map, TextInput.js:828. */
534
+ const std::unordered_map<std::string, std::string> &autoCompleteWebToAndroid() {
535
+ static const auto *table = new std::unordered_map<std::string, std::string>{
536
+ {"additional-name", "name-middle"},
537
+ {"address-line1", "postal-address-region"},
538
+ {"address-line2", "postal-address-locality"},
539
+ {"bday", "birthdate-full"},
540
+ {"bday-day", "birthdate-day"},
541
+ {"bday-month", "birthdate-month"},
542
+ {"bday-year", "birthdate-year"},
543
+ {"cc-csc", "cc-csc"},
544
+ {"cc-exp", "cc-exp"},
545
+ {"cc-exp-month", "cc-exp-month"},
546
+ {"cc-exp-year", "cc-exp-year"},
547
+ {"cc-number", "cc-number"},
548
+ {"country", "postal-address-country"},
549
+ {"current-password", "password"},
550
+ {"email", "email"},
551
+ {"family-name", "name-family"},
552
+ {"given-name", "name-given"},
553
+ {"honorific-prefix", "name-prefix"},
554
+ {"honorific-suffix", "name-suffix"},
555
+ {"name", "name"},
556
+ {"new-password", "password-new"},
557
+ {"off", "off"},
558
+ {"one-time-code", "sms-otp"},
559
+ {"postal-code", "postal-code"},
560
+ {"sex", "gender"},
561
+ {"street-address", "street-address"},
562
+ {"tel", "tel"},
563
+ {"tel-country-code", "tel-country-code"},
564
+ {"tel-national", "tel-national"},
565
+ {"username", "username"},
566
+ };
567
+ return *table;
568
+ }
569
+
570
+ /** RN's W3C autocomplete -> iOS `textContentType` map, TextInput.js:862. */
571
+ const std::unordered_map<std::string, std::string> &autoCompleteWebToTextContentType() {
572
+ static const auto *table = new std::unordered_map<std::string, std::string>{
573
+ {"additional-name", "middleName"},
574
+ {"address-line1", "streetAddressLine1"},
575
+ {"address-line2", "streetAddressLine2"},
576
+ {"bday", "birthdate"},
577
+ {"bday-day", "birthdateDay"},
578
+ {"bday-month", "birthdateMonth"},
579
+ {"bday-year", "birthdateYear"},
580
+ {"cc-additional-name", "creditCardMiddleName"},
581
+ {"cc-csc", "creditCardSecurityCode"},
582
+ {"cc-exp", "creditCardExpiration"},
583
+ {"cc-exp-month", "creditCardExpirationMonth"},
584
+ {"cc-exp-year", "creditCardExpirationYear"},
585
+ {"cc-family-name", "creditCardFamilyName"},
586
+ {"cc-given-name", "creditCardGivenName"},
587
+ {"cc-name", "creditCardName"},
588
+ {"cc-number", "creditCardNumber"},
589
+ {"cc-type", "creditCardType"},
590
+ {"country", "countryName"},
591
+ {"current-password", "password"},
592
+ {"email", "emailAddress"},
593
+ {"family-name", "familyName"},
594
+ {"given-name", "givenName"},
595
+ {"honorific-prefix", "namePrefix"},
596
+ {"honorific-suffix", "nameSuffix"},
597
+ {"name", "name"},
598
+ {"new-password", "newPassword"},
599
+ {"nickname", "nickname"},
600
+ {"off", "none"},
601
+ {"one-time-code", "oneTimeCode"},
602
+ {"organization", "organizationName"},
603
+ {"organization-title", "jobTitle"},
604
+ {"postal-code", "postalCode"},
605
+ {"street-address", "fullStreetAddress"},
606
+ {"tel", "telephoneNumber"},
607
+ {"url", "URL"},
608
+ {"username", "username"},
609
+ };
610
+ return *table;
611
+ }
612
+
613
+ /** The string a key holds, or null when it is absent or is not a string. */
614
+ const std::string *stringAt(const dynamic &props, const char *key) {
615
+ const dynamic *found = props.get_ptr(key);
616
+ if (found == nullptr || !found->isString()) return nullptr;
617
+ return &found->getString();
618
+ }
619
+
620
+ /**
621
+ * The bool a key holds, or nothing when it is absent or is not a bool.
622
+ *
623
+ * A VALUE, and it has to be. This returned `const bool *` into a single `static thread_local` slot
624
+ * until 2026-09-18, which meant any two results held at once silently aliased — the second read
625
+ * rewrote the first. Nothing had two live at a time, so nothing was wrong; the Android switch rule
626
+ * is the first caller that needs `disabled` and `accessibilityState.disabled` side by side, and it
627
+ * would have resolved every switch through whichever was read last.
628
+ */
629
+ std::optional<bool> boolAt(const dynamic &props, const char *key) {
630
+ const dynamic *found = props.get_ptr(key);
631
+ if (found == nullptr || !found->isBool()) return std::nullopt;
632
+ return found->getBool();
633
+ }
634
+
635
+ /** A safe lookup: the mapped token, or null when the map has no entry. The caller owns the fallback. */
636
+ const std::string *mappedToken(
637
+ const std::unordered_map<std::string, std::string> &table,
638
+ const std::string &token) {
639
+ const auto found = table.find(token);
640
+ return found == table.end() ? nullptr : &found->second;
641
+ }
642
+
643
+ /**
644
+ * RN's submitBehavior reconciliation, TextInput.js:559.
645
+ *
646
+ * It returns a value for an EMPTY bag, which is what makes it a RULE rather than a mapping: a
647
+ * singleline input with nothing authored still submits on return. An explicit `newline` on a
648
+ * singleline tag is coerced — there is no newline to insert.
649
+ */
650
+ std::string foldSubmitBehavior(
651
+ const std::string *submitBehavior,
652
+ std::optional<bool> blurOnSubmit,
653
+ bool isMultiline) {
654
+ if (submitBehavior != nullptr) {
655
+ if (!isMultiline && *submitBehavior == "newline") return "blurAndSubmit";
656
+ return *submitBehavior;
657
+ }
658
+ if (isMultiline) {
659
+ return blurOnSubmit.value_or(false) ? "blurAndSubmit" : "newline";
660
+ }
661
+ return blurOnSubmit.has_value() && !*blurOnSubmit ? "submit" : "blurAndSubmit";
662
+ }
663
+
664
+ /**
665
+ * The whole rule, applied to the bag on its way to the payload.
666
+ *
667
+ * WRITES ONLY WHAT IT RESOLVES and erases only the aliases, so an authored native name always
668
+ * survives: every branch below reads the native key first and falls back to the web one, which is
669
+ * RN's own precedence (TextInput.js:930-946).
670
+ */
671
+ dynamic foldTextInputAliases(const dynamic &props, bool isMultiline) {
672
+ dynamic out = props;
673
+
674
+ const std::string *inputMode = stringAt(props, "inputMode");
675
+ if (inputMode != nullptr && out.get_ptr("keyboardType") == nullptr) {
676
+ // `search` is the ONE token RN resolves per platform (TextInput.js:815-825): iOS has a dedicated
677
+ // search keyboard whose return key is a magnifier, every other host falls back to the default.
678
+ if (*inputMode == "search") {
679
+ #ifdef ANDROID
680
+ out["keyboardType"] = "default";
681
+ #else
682
+ out["keyboardType"] = "web-search";
683
+ #endif
684
+ } else {
685
+ const std::string *mapped = mappedToken(inputModeToKeyboardType(), *inputMode);
686
+ if (mapped != nullptr) out["keyboardType"] = *mapped;
687
+ }
688
+ }
689
+
690
+ const std::string *enterKeyHint = stringAt(props, "enterKeyHint");
691
+ if (enterKeyHint != nullptr && out.get_ptr("returnKeyType") == nullptr) {
692
+ const std::string *mapped = mappedToken(enterKeyHintToReturnKeyType(), *enterKeyHint);
693
+ if (mapped != nullptr) out["returnKeyType"] = *mapped;
694
+ }
695
+
696
+ // The web spelling is the NEGATION of the native one. Getting it backwards makes every read-only
697
+ // field editable, silently.
698
+ const std::optional<bool> readOnly = boolAt(props, "readOnly");
699
+ if (readOnly.has_value() && out.get_ptr("editable") == nullptr) {
700
+ out["editable"] = !*readOnly;
701
+ }
702
+
703
+ out["submitBehavior"] = foldSubmitBehavior(
704
+ stringAt(props, "submitBehavior"), boolAt(props, "blurOnSubmit"), isMultiline);
705
+
706
+ // RN's three selection colours coalesce onto one authored value, so writing `selectionColor` alone
707
+ // gets a matching caret and handle.
708
+ const dynamic *selectionColor = props.get_ptr("selectionColor");
709
+ if (selectionColor != nullptr && !selectionColor->isNull()) {
710
+ if (out.get_ptr("cursorColor") == nullptr) out["cursorColor"] = *selectionColor;
711
+ if (out.get_ptr("selectionHandleColor") == nullptr) {
712
+ out["selectionHandleColor"] = *selectionColor;
713
+ }
714
+ }
715
+
716
+ // RN resolves BOTH native props from the one W3C token (TextInput.js:938). Android reads
717
+ // `autoComplete` and iOS reads `textContentType`; each is inert on the other platform, so emitting
718
+ // both is safe and is what keeps this fold platform-agnostic. A token with no Android entry falls
719
+ // back to ITSELF (RN's `?? autoComplete`); one with no iOS entry leaves `textContentType` unset.
720
+ const std::string *autoComplete = stringAt(props, "autoComplete");
721
+ if (autoComplete != nullptr) {
722
+ const std::string *android = mappedToken(autoCompleteWebToAndroid(), *autoComplete);
723
+ out["autoComplete"] = android != nullptr ? *android : *autoComplete;
724
+ if (out.get_ptr("textContentType") == nullptr) {
725
+ const std::string *ios = mappedToken(autoCompleteWebToTextContentType(), *autoComplete);
726
+ if (ios != nullptr) out["textContentType"] = *ios;
727
+ }
728
+ }
729
+
730
+ // `inputMode: 'none'` is how the web spells "focusable but no keyboard".
731
+ if (inputMode != nullptr && out.get_ptr("showSoftInputOnFocus") == nullptr) {
732
+ out["showSoftInputOnFocus"] = *inputMode != "none";
733
+ }
734
+
735
+ // ANDROID ONLY, and that is F-76 rather than tidiness: iOS's `RCTSinglelineTextInputView`
736
+ // ViewConfig does not declare `underlineColorAndroid` at all, so RN's own
737
+ // `ReactNativeAttributePayload.create` filters it out and it never leaves JS there. We have no such
738
+ // filter, so defaulting it unconditionally sent a key every iOS view silently dropped — one wire
739
+ // slot, one interned string, one hashed RawProps entry, per TextInput, for nothing.
740
+ #ifdef ANDROID
741
+ if (out.get_ptr("underlineColorAndroid") == nullptr) {
742
+ out["underlineColorAndroid"] = "transparent";
743
+ }
744
+ #endif
745
+
746
+ // The aliases themselves must NOT ride along: none is a native prop, and leaving one in the payload
747
+ // is how a reader concludes the rule ran when it did not.
748
+ out.erase("inputMode");
749
+ out.erase("enterKeyHint");
750
+ out.erase("readOnly");
751
+ out.erase("blurOnSubmit");
752
+ return out;
753
+ }
754
+
755
+ /**
756
+ * Which tags the pressable rule below belongs to — the three that a pressable IS, in RN's own terms.
757
+ *
758
+ * `touchable-opacity` is a pressable plus a fade (`TouchableOpacity.js` wraps Pressability), and
759
+ * `button` is a touchable plus a label on iOS and a TouchableNativeFeedback on Android
760
+ * (`Button.js:283`) — so on either platform a `<button>`'s platform half runs through this. Both
761
+ * composed the same function in JS before it moved, which is what this list is the record of.
762
+ *
763
+ * A composed tag has to be NAMED here because the wire carries what the node IS, not what its
764
+ * behavior was built out of — the browser's arrangement, and the one worth keeping: a reader asks
765
+ * "what does a `<button>` send" and this answers it without tracing a composition.
766
+ *
767
+ * `touchable-highlight` is deliberately ABSENT and always was: its behavior replaces the fold rather
768
+ * than composing it, so it has never carried the machine-key strip. That is a gap in it, not here.
769
+ */
770
+ bool usesPressableRule(const std::string &tagName) {
771
+ return tagName == "pressable" || tagName == "touchable-opacity" ||
772
+ tagName == "touchable-highlight" || tagName == "button";
773
+ }
774
+
775
+ /**
776
+ * The three tags built on the TOUCHABLE feedback machine rather than on bare Pressable. They take a
777
+ * second strip, below — `pressable` does not, because none of these names is one of its props.
778
+ *
779
+ * `button` is here because it composes TouchableOpacity (`Button.js:283`), so it inherits the same
780
+ * consumed set even though an app rarely spells those props on it.
781
+ */
782
+ bool usesTouchableFeedbackRule(const std::string &tagName) {
783
+ return tagName == "touchable-opacity" || tagName == "touchable-highlight" ||
784
+ tagName == "button";
785
+ }
786
+
787
+ /**
788
+ * The tags whose `focusable` takes RN's THREE-leg touchable form rather than Pressable's plain one:
789
+ * `focusable !== false && onPress !== undefined && !disabled`
790
+ * (`TouchableOpacity.js:336-339`, `TouchableHighlight.js` identically).
791
+ *
792
+ * `button` is deliberately NOT here even though it composes TouchableOpacity, and the reason is
793
+ * precedence rather than shape. Button resolves `disabled` three ways —
794
+ * `props.disabled ?? aria-disabled ?? accessibilityState.disabled` (`Button.js:331,337`) — through
795
+ * the same projection its derived children read, and it keeps a fold for its Android view style and
796
+ * ripple regardless. So moving only its `focusable` here would duplicate that precedence in two
797
+ * places and buy no crossing back. It stays on the plain form, whose `!== false` leaves the answer
798
+ * its own fold already computed untouched — which is how the two compose today.
799
+ */
800
+ bool usesTouchableFocusableRule(const std::string &tagName) {
801
+ return tagName == "touchable-opacity" || tagName == "touchable-highlight";
802
+ }
803
+
804
+ /**
805
+ * What the feedback machine CONSUMES. RN forwards none of them to the View it renders — read the
806
+ * prop lists at `TouchableOpacity.js:302-345` and `TouchableHighlight.js:336-378`: every name they
807
+ * pass is spelled out, and not one of these is among them.
808
+ *
809
+ * Two are FUNCTIONS. A callback reaching a native prop bag is not a cosmetic leak; it is a value no
810
+ * ViewConfig declares, crossing for a view that will never call it.
811
+ */
812
+ const std::array<const char *, 8> kTouchableFeedbackKeys = {
813
+ "activeOpacity",
814
+ "underlayColor",
815
+ "onShowUnderlay",
816
+ "onHideUnderlay",
817
+ "delayPressIn",
818
+ "delayPressOut",
819
+ // `TouchableHighlight.js:205` forwards this to Pressability as `android_disableSound`
820
+ // (`kPressableMachineKeys` strips that name); the authored name itself never reaches a view.
821
+ "touchSoundDisabled",
822
+ // `TouchableOpacity.js:186`/`TouchableHighlight.js:194` forward this to Pressability as
823
+ // `cancelable` (`kPressableMachineKeys` strips that name); the authored name itself is never
824
+ // one of the props either component passes to the View it renders.
825
+ "rejectResponderTermination",
826
+ };
827
+
828
+ /**
829
+ * RN's own two feedback defaults, `TouchableHighlight.js:258-268`. Constants of the PLATFORM — every
830
+ * TouchableHighlight in every app gets them unless it says otherwise — which is what makes them the
831
+ * engine's and not any component's.
832
+ */
833
+ constexpr double kHighlightChildOpacity = 0.85;
834
+ constexpr const char *kHighlightUnderlayColor = "black";
835
+
836
+ /**
837
+ * TouchableHighlight's UNDERLAY, and the last props-shaped rule in the migration.
838
+ *
839
+ * ONE NODE, which is the simplification every adapter already shipped rather than one this rule
840
+ * invents. RN renders a container View carrying the underlay and CLONES an extra opacity style onto
841
+ * its single child (`TouchableHighlight.js:189, 258-268`); every wrapper here folds BOTH onto the one
842
+ * node instead, because splitting them needs a child to target and a framework component holding an
843
+ * opaque children slot cannot reach one safely. The port keeps that, it does not reopen it.
844
+ *
845
+ * TWO INPUTS THAT ARE NOT PROPS, both already crossing:
846
+ * `underlayShown` the feedback bit — `kOpSetUnderlayShown`, flipped by the JS hold timer
847
+ * `hasAnyPressListener` RN's `_hasPressHandler` (`:296-302`), any of four owned names
848
+ *
849
+ * WHY `authored` AND NOT THE BAG. `foldPressableProps` has already run and stripped `underlayColor`
850
+ * and `activeOpacity` — they are in `kTouchableFeedbackKeys`, because RN forwards neither to the View
851
+ * it renders. Read from the bag they would both be absent here and every underlay would silently be
852
+ * black at 0.85 whatever the app asked for. Trap A, in the form that makes it a SILENT wrong answer
853
+ * rather than a missing key.
854
+ *
855
+ * COMPOSES, never replaces: the two styles go OVER the author's, matching the order RN appends them
856
+ * in. Reversed, a highlight with a background colour of its own would never visibly respond.
857
+ */
858
+ dynamic foldTouchableHighlightUnderlay(
859
+ const dynamic &props,
860
+ const dynamic &authored,
861
+ const ISelf &self) {
862
+ // RN's snapshot affordance (`TouchableHighlight.js:61,189,284-286`): it paints the underlay with no
863
+ // gesture, and `_hideUnderlay` returns early on it, so the pin LATCHES. Both halves are this one
864
+ // expression — a rule that ignores the live bit needs nothing from the machine to stay latched.
865
+ //
866
+ // IT BYPASSES THE PRESS-HANDLER GATE, and that asymmetry is upstream's rather than an oversight.
867
+ // `_showUnderlay` checks `_hasPressHandler` (`:271`), but the INITIAL state is
868
+ // `testOnly_pressed === true ? this._createExtraStyles() : null` with no such check — so a
869
+ // decorative control still snapshots pressed, which is what a snapshot of one needs. Reproducing
870
+ // the gate here would look more consistent and be wrong.
871
+ //
872
+ // READ OFF `authored`, which matters here rather than being a habit: this runs after
873
+ // `foldPressableProps`, which strips the name (`kPressableMachineKeys`) so it never reaches
874
+ // Fabric. Read from the bag it would already be gone. Trap A, in the form where the rule that
875
+ // erases a key and the rule that uses it are two different rules.
876
+ const bool forced = boolAt(authored, "testOnly_pressed").value_or(false);
877
+ if (!forced && (!self.underlayShown || !self.hasAnyPressListener))
878
+ return props;
879
+
880
+ dynamic underlay = dynamic::object();
881
+ const dynamic *color = authored.get_ptr("underlayColor");
882
+ // PRESENCE, not truthiness, and the difference is a feature rather than a nicety. Upstream is
883
+ // `underlayColor === undefined ? 'black' : underlayColor` (`TouchableHighlight.js:261-265`) — a
884
+ // STRICT check, so `underlayColor={null}` travels through and suppresses the tint. That is how an
885
+ // app says "this control responds, but not with a colour". The `!isNull()` that used to be here
886
+ // collapsed it onto the default and painted black on exactly the control that asked for none.
887
+ //
888
+ // Its NEIGHBOUR is deliberately not like this: the opacity is `activeOpacity ?? 0.85` (`:260`), so
889
+ // null there really does mean absent. Two defaults, two spellings, both upstream's.
890
+ underlay["backgroundColor"] =
891
+ color != nullptr ? *color : dynamic(kHighlightUnderlayColor);
892
+
893
+ dynamic composed = dynamic::array();
894
+ const dynamic *style = props.get_ptr("style");
895
+ if (style != nullptr) composed.push_back(*style);
896
+ composed.push_back(std::move(underlay));
897
+
898
+ dynamic out = props;
899
+ out["style"] = std::move(composed);
900
+ return out;
901
+ }
902
+
903
+ /**
904
+ * The OTHER half of the underlay — `{opacity}` on the touchable's single child.
905
+ *
906
+ * A DESCENDANT RULE, keyed on the owner's tag, and the direction is what makes it possible at all.
907
+ * `IFirstChild` reads DOWN and cannot serve this: a rule returns the payload of the node it runs on,
908
+ * so it can never write onto a child. The child reads UP instead, the way the clone-onto-child port
909
+ * already does — and the field it needs, `underlayShown`, had to join `IOwner` because it lives in
910
+ * `ISelf`, which is the node's OWN state and says nothing about a parent.
911
+ *
912
+ * WHY TWO NODES AT ALL, since every adapter shipped one and this file's other rule kept that: an
913
+ * `opacity` beside the underlay's `backgroundColor` fades the underlay ITSELF, so `underlayColor:
914
+ * 'black'` painted grey. RN never puts them together — `_createExtraStyles` builds the pair
915
+ * (`TouchableHighlight.js:258-266`) and the render applies each to its own box (`:358-361` for the
916
+ * container, `:379-383` for `cloneElement`). One is the whole population of children there,
917
+ * `React.Children.only` (`:306`).
918
+ *
919
+ * THE GATE IS THE OWNER'S, repeated rather than shared, because the two rules run on two nodes and
920
+ * neither can see the other's answer: `testOnly_pressed` latches with no gesture and bypasses the
921
+ * press-handler check, exactly as upstream's initial state does (`:189-190`). Read off the OWNER's
922
+ * props, since this node's own bag has neither.
923
+ *
924
+ * COMPOSES OVER, matching `:379-383` — a child that spells its own `opacity` still dims. Reversed,
925
+ * any child with an explicit opacity would sit there visibly unresponsive to touch.
926
+ */
927
+ dynamic foldTouchableHighlightChild(const dynamic &props, const IOwner &owner) {
928
+ if (owner.props == nullptr) return props;
929
+ const bool forced = boolAt(*owner.props, "testOnly_pressed").value_or(false);
930
+ if (!forced && (!owner.underlayShown || !owner.hasAnyPressListener)) return props;
931
+
932
+ dynamic child = dynamic::object();
933
+ const dynamic *opacity = owner.props->get_ptr("activeOpacity");
934
+ child["opacity"] = opacity != nullptr && opacity->isNumber()
935
+ ? *opacity
936
+ : dynamic(kHighlightChildOpacity);
937
+
938
+ dynamic composed = dynamic::array();
939
+ const dynamic *style = props.get_ptr("style");
940
+ if (style != nullptr) composed.push_back(*style);
941
+ composed.push_back(std::move(child));
942
+
943
+ dynamic out = props;
944
+ out["style"] = std::move(composed);
945
+ return out;
946
+ }
947
+
948
+ /**
949
+ * The per-axis base style every ScrollView box carries, and the ONE place it is spelled.
950
+ *
951
+ * IT HAD A SECOND COPY IN JS UNTIL 2026-09-18, held by `scroll-view-base-parity.itest.ts` because
952
+ * the Android wrap's split needed the value on the JS side. The split is here now, so the copy is
953
+ * gone and the parity test with it — a mirror that becomes unnecessary is deleted rather than
954
+ * guarded, which is the outcome that test existed to make possible.
955
+ */
956
+ dynamic scrollViewBaseStyle(bool isHorizontal) {
957
+ dynamic base = dynamic::object();
958
+ base["flexGrow"] = 1;
959
+ base["flexShrink"] = 1;
960
+ base["flexDirection"] = isHorizontal ? "row" : "column";
961
+ base["overflow"] = "scroll";
962
+ return base;
963
+ }
964
+
965
+ /** `[base, authored]` — base UNDER, so an explicit user value wins. `addStyle` reads in order. */
966
+ dynamic composeUnder(dynamic base, const dynamic *authored) {
967
+ dynamic composed = dynamic::array(std::move(base));
968
+ if (authored != nullptr) composed.push_back(*authored);
969
+ return composed;
970
+ }
971
+
972
+ /**
973
+ * RN's `splitLayoutProps` key partition (`StyleSheet/splitLayoutProps.js`): the keys that belong on
974
+ * the OUTER box when a layout-affecting wrapper sits between the laid-out frame and the visual
975
+ * content. Everything NOT here (background*, padding*, border*, opacity, overflow, …) is VISUAL and
976
+ * stays on the inner view.
977
+ *
978
+ * Replicated from upstream's switch cases. A key missing from this set does not fail loudly — it
979
+ * quietly stays on the inner box, where a margin has no effect — so diff it against upstream rather
980
+ * than reading it for plausibility, the same instruction `kRoleToAccessibilityRole` carries.
981
+ */
982
+ const std::unordered_set<std::string> kScrollLayoutKeys = {
983
+ "margin", "marginHorizontal", "marginVertical", "marginBottom",
984
+ "marginTop", "marginLeft", "marginRight", "flex",
985
+ "flexGrow", "flexShrink", "flexBasis", "alignSelf",
986
+ "height", "minHeight", "maxHeight", "width",
987
+ "minWidth", "maxWidth", "position", "left",
988
+ "right", "bottom", "top", "transform",
989
+ "transformOrigin", "rowGap", "columnGap", "gap",
990
+ };
991
+
992
+ /** Flatten a (possibly nested) style slot to one object, RAW — no `processValue`. */
993
+ void flattenStyleInto(dynamic &out, const dynamic &style) {
994
+ if (style.isArray()) {
995
+ for (const auto &entry : style) flattenStyleInto(out, entry);
996
+ return;
997
+ }
998
+ if (!style.isObject()) return;
999
+ for (const auto &pair : style.items()) {
1000
+ if (!pair.first.isString()) continue;
1001
+ out[pair.first.getString()] = pair.second;
1002
+ }
1003
+ }
1004
+
1005
+ struct IScrollStyleSplit {
1006
+ dynamic outer;
1007
+ dynamic inner;
1008
+ };
1009
+
1010
+ /**
1011
+ * The whole Android wrap style decision: the layout/visual split AND the axis base composed onto
1012
+ * BOTH boxes (`ScrollView.js:1856`, `StyleSheet.compose(baseStyle, outer)` beside
1013
+ * `compose(baseStyle, inner)`).
1014
+ *
1015
+ * The second half is the one every adapter had dropped from the wrapper before this became one
1016
+ * function: an `AndroidSwipeRefreshLayout` with no explicit user layout style lost `flexGrow: 1` and
1017
+ * collapsed to its content height inside a flex parent, where RN's grows.
1018
+ *
1019
+ * ONE FUNCTION SERVING TWO NODES is the reason the pair can be a rule at all — the scroller asks for
1020
+ * `.inner` off its own bag and the wrapper asks for `.outer` off its child's, and neither can be
1021
+ * right while the other is wrong.
1022
+ */
1023
+ IScrollStyleSplit splitScrollViewStyle(bool isHorizontal, const dynamic *authored) {
1024
+ dynamic flat = dynamic::object();
1025
+ if (authored != nullptr) flattenStyleInto(flat, *authored);
1026
+
1027
+ dynamic outer = dynamic::object();
1028
+ dynamic inner = dynamic::object();
1029
+ for (const auto &pair : flat.items()) {
1030
+ const std::string &key = pair.first.getString();
1031
+ if (kScrollLayoutKeys.count(key) != 0) outer[key] = pair.second;
1032
+ else inner[key] = pair.second;
1033
+ }
1034
+ return IScrollStyleSplit{
1035
+ composeUnder(scrollViewBaseStyle(isHorizontal), &outer),
1036
+ composeUnder(scrollViewBaseStyle(isHorizontal), &inner)};
1037
+ }
1038
+
1039
+ /** Which tags are a scroll view, for the wrapper rule that has to ask about its child. */
1040
+ bool isScrollViewTag(const char *tagName) {
1041
+ if (tagName == nullptr) return false;
1042
+ return std::strcmp(tagName, "scroll-view") == 0 ||
1043
+ std::strcmp(tagName, "horizontal-scroll-view") == 0;
1044
+ }
1045
+
1046
+ /**
1047
+ * The Android RefreshControl WRAPPER — the first rule in the engine that reads DOWNWARD.
1048
+ *
1049
+ * An Android ScrollView holds exactly one child, so a sibling refresh control is an `addViewAt`
1050
+ * crash rather than a layout mistake. RN inverts the tree and splits the scroller's style across the
1051
+ * two boxes; this node is the outer one and its frame is the LAYOUT half of a style the app wrote on
1052
+ * the node BELOW it. That is the read `IOwner` cannot do and `IFirstChild` exists for.
1053
+ *
1054
+ * REPLACING the app's own `style` is parity rather than a liberty: RN reaches the same place through
1055
+ * `cloneElement(refreshControl, {style: outer}, …)`, which likewise overrides whatever the refresh
1056
+ * control was given.
1057
+ *
1058
+ * A REFRESH CONTROL THAT WRAPS NOTHING IS UNTOUCHED, and the guard is the child's TAG rather than
1059
+ * its presence. On iOS the control is claimed BESIDE the content and an app may mount one alone;
1060
+ * either way inventing a base style for an axis nobody chose would paint a `flexGrow` onto a
1061
+ * standalone control.
1062
+ */
1063
+ dynamic foldRefreshWrapperProps(const dynamic &props, const IFirstChild &child) {
1064
+ if (!isScrollViewTag(child.tagName) || child.props == nullptr) return props;
1065
+
1066
+ dynamic out = props;
1067
+ out["style"] = splitScrollViewStyle(
1068
+ std::strcmp(child.tagName, "horizontal-scroll-view") == 0,
1069
+ child.props->get_ptr("style"))
1070
+ .outer;
1071
+ return out;
1072
+ }
1073
+
1074
+ /**
1075
+ * ScrollView's owner, both axes (`ScrollView.js:1753-1761` and the wrapper body).
1076
+ *
1077
+ * THE TAG IS THE ONLY AXIS INPUT, which is what keeps the three halves of the axis from disagreeing:
1078
+ * the scroller's own `horizontal`, the base style's `flexDirection`, and the content node's row
1079
+ * style all derive from ONE thing. RN derives them from one prop so a mismatch is unrepresentable
1080
+ * there; here the tag plays that part, and on iOS both tags really are `RCTScrollView`, so a stray
1081
+ * `horizontal` would otherwise produce a vertical scroller over a content node with no row style —
1082
+ * a shape RN cannot make.
1083
+ *
1084
+ * So an app writing `horizontal` on the vertical tag is IGNORED, and ignoring it in silence is the
1085
+ * failure the warning below exists to prevent. This is the first rule in the engine to WARN rather
1086
+ * than crash, and it is why `SymbioteDebug.h` had to exist before this port could happen at all.
1087
+ *
1088
+ * THE BOUNCE PAIR IS ASYMMETRIC and that is upstream's shape rather than an oversight: RN falls back
1089
+ * to `this.props.horizontal`, which is unset on a vertical view, so `alwaysBounceHorizontal` simply
1090
+ * never resolves there and never reaches the payload. Both names are declared in every adapter's
1091
+ * prop type and computed in none — vertical never bounced by default.
1092
+ *
1093
+ * THE TWO STRIPS are consumed by the behavior and declared by no ViewConfig — neither name appears
1094
+ * anywhere under `ReactCommon/react/renderer/components/scrollview`. `stickyHeaderIndices` decides
1095
+ * which children become a `sticky-header`; `invertStickyHeaders` feeds the pin. A key Fabric does
1096
+ * not know throws nothing, logs nothing and paints nothing, so the strip is only ever visible in a
1097
+ * payload test.
1098
+ */
1099
+ dynamic foldScrollViewProps(const dynamic &props, bool isHorizontal, bool isWrapped) {
1100
+ dynamic out = props;
1101
+
1102
+ const dynamic *authored = props.get_ptr("style");
1103
+ // WRAPPED IS A DIFFERENT COMPOSITION, not an extra one: on Android a RefreshControl becomes this
1104
+ // node's PARENT and takes the LAYOUT half of the app's style with it, so composing `[base,
1105
+ // authored]` here would put every margin on both boxes. See `foldRefreshWrapperProps` for the
1106
+ // other half and `IFirstChild` for why the pair can be one decision at all.
1107
+ //
1108
+ // TOPOLOGY DECIDES, NOT `#ifdef ANDROID`, and that is the same win `Switch`/`AndroidSwitch` gives:
1109
+ // iOS claims the refresh control BESIDE the content, so a scroll view is never one's child there
1110
+ // and this branch cannot fire whatever the host was compiled for.
1111
+ out["style"] = isWrapped
1112
+ ? splitScrollViewStyle(isHorizontal, authored).inner
1113
+ : composeUnder(scrollViewBaseStyle(isHorizontal), authored);
1114
+
1115
+ out["nestedScrollEnabled"] = boolAt(props, "nestedScrollEnabled").value_or(true);
1116
+
1117
+ const std::optional<bool> authoredAxis = boolAt(props, "horizontal");
1118
+ if (authoredAxis.has_value() && *authoredAxis != isHorizontal) {
1119
+ SYMBIOTE_DLOG(
1120
+ std::string("ScrollView: horizontal=") +
1121
+ (*authoredAxis ? "true" : "false") +
1122
+ " ignored — the axis comes from the tag; write <" +
1123
+ (isHorizontal ? "scroll-view" : "horizontal-scroll-view") + "> instead");
1124
+ }
1125
+ out.erase("horizontal");
1126
+ if (isHorizontal) out["horizontal"] = true;
1127
+
1128
+ if (props.get_ptr("alwaysBounceHorizontal") == nullptr && isHorizontal)
1129
+ out["alwaysBounceHorizontal"] = true;
1130
+ if (props.get_ptr("alwaysBounceVertical") == nullptr)
1131
+ out["alwaysBounceVertical"] = !isHorizontal;
1132
+
1133
+ out.erase("stickyHeaderIndices");
1134
+ out.erase("invertStickyHeaders");
1135
+
1136
+ // ONE AUTHORED PROP OVER TWO INCOMPATIBLE NATIVE MEANINGS, which is why the expression INVERTS
1137
+ // between the platforms (`ScrollView.js:1810-1821`). Read the native sides before touching it —
1138
+ // the JS is a normalisation and says nothing about why:
1139
+ //
1140
+ // iOS snapping is RN's OWN code, in the `scrollViewWillEndDragging:` delegate, rewriting
1141
+ // `targetContentOffset` (`RCTScrollView.m:732,813`). `pagingEnabled` is UIKit's
1142
+ // property on UIScrollView — a COMPETING implementation that decides the offset
1143
+ // itself, so RN turns it off to let its own snap code run.
1144
+ // Android `mPagingEnabled` is the GATE on the snap code: `fling()` reaches `flingAndSnap()`
1145
+ // only through it (`ReactScrollView.java:804`), and the snap props are read INSIDE
1146
+ // that (`:1071`). Without paging on, `snapToInterval` is read by nothing.
1147
+ //
1148
+ // NOT A PORT — no JS of ours ever did this, on either platform. `pagingEnabled` was forwarded raw
1149
+ // while all three props sat in the public prop surface, so the failure was silent on both sides:
1150
+ // every prop reached Fabric, nothing errored, and the scroller did the other thing. An iOS app
1151
+ // pairing `pagingEnabled` with `snapToInterval` got paging and no snapping; an Android app that
1152
+ // set only `snapToInterval` got neither.
1153
+ //
1154
+ // Resolved on EVERY scroll view rather than only when it changes something, as RN does: leaving
1155
+ // the key absent would let a `true` from a previous commit stand after the app added a snap prop.
1156
+ const bool snaps = props.get_ptr("snapToInterval") != nullptr ||
1157
+ props.get_ptr("snapToOffsets") != nullptr;
1158
+ const bool asksPaging = boolAt(props, "pagingEnabled").value_or(false);
1159
+ #ifdef ANDROID
1160
+ out["pagingEnabled"] = asksPaging || snaps;
1161
+ #else
1162
+ out["pagingEnabled"] = asksPaging && !snaps;
1163
+ #endif
1164
+
1165
+ // `normal` / `fast` are RN's two names for a platform constant (`ScrollView.js`'s
1166
+ // `decelerationRate` prop); a number passes through as itself. The values are iOS's and Android's
1167
+ // own, so this is the one branch here that a component name cannot decide — on iOS BOTH tags are
1168
+ // `RCTScrollView`, so the name says nothing about the platform.
1169
+ const dynamic *rate = props.get_ptr("decelerationRate");
1170
+ if (rate != nullptr && rate->isString()) {
1171
+ const std::string named = rate->asString();
1172
+ #ifdef ANDROID
1173
+ if (named == "normal") out["decelerationRate"] = 0.985;
1174
+ else if (named == "fast") out["decelerationRate"] = 0.9;
1175
+ #else
1176
+ if (named == "normal") out["decelerationRate"] = 0.998;
1177
+ else if (named == "fast") out["decelerationRate"] = 0.99;
1178
+ #endif
1179
+ }
1180
+ return out;
1181
+ }
1182
+
1183
+ /**
1184
+ * ScrollView's CONTENT node — the first rule here that reads the node ABOVE it.
1185
+ *
1186
+ * Two halves from two places. The row direction is a constant of this node's OWN tag
1187
+ * (`horizontal-scroll-content`), composed OVER the app's `contentContainerStyle` because that is the
1188
+ * order RN writes it in (`[contentContainerStyle, {flexDirection:'row'}]`) — the opposite precedence
1189
+ * from the owner's base style, and deliberately so.
1190
+ *
1191
+ * `collapsableChildren` is the half that needed `ownerProps`: both names that decide it stay on the
1192
+ * SCROLLER, and the node that must stop collapsing is this one. A Yoga-collapsed content view takes
1193
+ * the scroll metrics with it, and an anchored scroll needs its children to keep their identity.
1194
+ *
1195
+ * PRESENCE decides, not truthiness — `snapToAlignment: 'start'` and an empty
1196
+ * `maintainVisibleContentPosition` are both real requests. And the key is written ONLY when false,
1197
+ * matching every wrapper: RN sends `collapsableChildren={!preserveChildren}`, so an explicit `true`
1198
+ * is the native default and one more key on every scroll view that ever renders.
1199
+ */
1200
+ dynamic foldScrollContentProps(
1201
+ const dynamic &props,
1202
+ bool isHorizontal,
1203
+ const dynamic *ownerProps) {
1204
+ // `ScrollView.js:1731-1733`, and the `snapToAlignment` leg is ANDROID-ONLY there. It was written
1205
+ // here without the gate until 2026-09-18, so an iOS scroller that merely snapped stopped Yoga
1206
+ // flattening its children — work RN never asks for. A compile-time branch rather than a component
1207
+ // name, for the reason `android_ripple` already is: a content node commits as
1208
+ // `RCTScrollContentView` on both platforms, so nothing on the wire tells them apart.
1209
+ const bool snaps =
1210
+ #ifdef ANDROID
1211
+ ownerProps != nullptr && ownerProps->get_ptr("snapToAlignment") != nullptr;
1212
+ #else
1213
+ false;
1214
+ #endif
1215
+ const bool preserves = snaps ||
1216
+ (ownerProps != nullptr &&
1217
+ ownerProps->get_ptr("maintainVisibleContentPosition") != nullptr);
1218
+
1219
+ dynamic out = props;
1220
+ // `ScrollView.js:1747` — UNCONDITIONAL on every content view, both axes. Yoga may collapse a view
1221
+ // that only groups children, and a collapsed content node takes the scroll metrics with it.
1222
+ //
1223
+ // It was a `setProp` in `buildStructure` until 2026-09-18, which reached the payload by a route
1224
+ // that had nothing to do with this rule and cost a crossing per scroll view to say a constant.
1225
+ // Writing it here is also what RETIRED the identity return this rule used to open with: there is
1226
+ // no longer a content node with nothing to add, so the fast path had no case left to serve.
1227
+ out["collapsable"] = false;
1228
+ if (isHorizontal) {
1229
+ dynamic row = dynamic::object();
1230
+ row["flexDirection"] = "row";
1231
+ dynamic composed = dynamic::array();
1232
+ const dynamic *authored = props.get_ptr("style");
1233
+ if (authored != nullptr) composed.push_back(*authored);
1234
+ composed.push_back(std::move(row));
1235
+ out["style"] = std::move(composed);
1236
+ }
1237
+ if (preserves) out["collapsableChildren"] = false;
1238
+ return out;
1239
+ }
1240
+
1241
+ /**
1242
+ * ImageBackground's wrapper (`ImageBackground.js:75`), and the whole rule is one key.
1243
+ *
1244
+ * iOS's Smart Invert inverts colours for accessibility, and a PHOTOGRAPH is exactly what must not be
1245
+ * inverted — a background image shown as its own negative is the case the prop exists for. RN sets
1246
+ * it on every ImageBackground it renders, so it is unconditional here too.
1247
+ *
1248
+ * Only the OWNER's rule. The inner image's style is DERIVED from this node's live style (RN proxies
1249
+ * the wrapper's width/height onto the image so it fills the box rather than collapsing to the
1250
+ * source's intrinsic size), which reads a second node and therefore stays a JS fold — composition,
1251
+ * not platform.
1252
+ */
1253
+ dynamic foldImageBackgroundProps(const dynamic &props) {
1254
+ dynamic out = props;
1255
+ out["accessibilityIgnoresInvertColors"] = true;
1256
+ return out;
1257
+ }
1258
+
1259
+ /**
1260
+ * The INNER image of an ImageBackground, over and above the ordinary image rule its tag also gets.
1261
+ *
1262
+ * RN'S OWN WORKAROUND, and its comment calls it one (`ImageBackground.js:86-96`): an RN Image
1263
+ * overwrites its own width/height from the SOURCE's intrinsic size, which fights the box the app
1264
+ * sized. So the wrapper's explicit dimensions are proxied back onto the image, under an absolute
1265
+ * fill, and the photograph covers the box instead of collapsing to the bitmap.
1266
+ *
1267
+ * Both inputs live on the node ABOVE — the app writes `style` on the `<image-background>` and
1268
+ * `IMAGE_BACKGROUND_HOST_PROPS` keeps it there — which is why this was the last fold in the file to
1269
+ * move and why `ownerProps` is what let it.
1270
+ *
1271
+ * READ THROUGH `lastStyleValue`, not off a plain object: a class name resolves into the owner's
1272
+ * style slot as an ARRAY (`pushClassStyle`), so an app that sizes its background with a stylesheet
1273
+ * rule — the common case — has no inline object to read.
1274
+ *
1275
+ * `imageStyle` arrives as this node's own `style` (the behavior's `slotProps` renames it) and is
1276
+ * composed LAST, so a caller beats both the fill and the proxy.
1277
+ *
1278
+ * ALSO CARRIES `importantForAccessibility` DOWN FROM THE OWNER (`ImageBackground.js:67,76,82`
1279
+ * destructures it out of `...props` and reapplies it explicitly to BOTH the wrapper and the image —
1280
+ * one accessibility subtree, so both halves must agree). `IMAGE_BACKGROUND_HOST_PROPS` keeps the
1281
+ * authored value on the owner; this is the image's half of the same rule, read the same way the box
1282
+ * proxy already reads `ownerProps`.
1283
+ */
1284
+ dynamic foldImageBackgroundImageProps(
1285
+ const dynamic &props,
1286
+ const dynamic *ownerProps) {
1287
+ dynamic fill = dynamic::object();
1288
+ fill["position"] = "absolute";
1289
+ fill["left"] = 0;
1290
+ fill["right"] = 0;
1291
+ fill["top"] = 0;
1292
+ fill["bottom"] = 0;
1293
+
1294
+ dynamic box = dynamic::object();
1295
+ const dynamic *ownerStyle =
1296
+ ownerProps == nullptr ? nullptr : ownerProps->get_ptr("style");
1297
+ if (ownerStyle != nullptr) {
1298
+ // Written only when the owner named one. A `width: undefined` would be the same as writing
1299
+ // nothing, but a 0 would collapse the image — so an unsized (flex) owner proxies NOTHING.
1300
+ const dynamic *width = lastStyleValue(*ownerStyle, "width");
1301
+ const dynamic *height = lastStyleValue(*ownerStyle, "height");
1302
+ if (width != nullptr) box["width"] = *width;
1303
+ if (height != nullptr) box["height"] = *height;
1304
+ }
1305
+
1306
+ dynamic composed = dynamic::array(std::move(fill), std::move(box));
1307
+ const dynamic *own = props.get_ptr("style");
1308
+ if (own != nullptr) composed.push_back(*own);
1309
+
1310
+ dynamic out = props;
1311
+ out["style"] = std::move(composed);
1312
+
1313
+ const dynamic *important = ownerProps == nullptr
1314
+ ? nullptr
1315
+ : ownerProps->get_ptr("importantForAccessibility");
1316
+ if (important != nullptr) out["importantForAccessibility"] = *important;
1317
+
1318
+ return out;
1319
+ }
1320
+
1321
+ // The two native spinners. Which one a tag resolves to is the PLATFORM split, and branching on the
1322
+ // component name rather than on `#ifdef` keeps both halves reachable from one test build — the same
1323
+ // choice `foldSwitchProps` makes for `Switch` / `AndroidSwitch`.
1324
+ constexpr const char *kAndroidProgressBar = "AndroidProgressBar";
1325
+
1326
+ // The fixed pixel boxes RN gives its two named sizes (`styles.sizeSmall` / `styles.sizeLarge`).
1327
+ constexpr double kSpinnerSmallPx = 20;
1328
+ constexpr double kSpinnerLargePx = 36;
1329
+
1330
+ // RN's iOS default spinner colour (`ActivityIndicator.js:25`, GRAY). Android's default is the THEME,
1331
+ // which is expressed by OMITTING the key — see below.
1332
+ constexpr const char *kSpinnerIosDefaultColor = "#999999";
1333
+
1334
+ /**
1335
+ * The centering View RN wraps its spinner in (`ActivityIndicator.js:112-114`):
1336
+ * `StyleSheet.compose(styles.container, style)`.
1337
+ *
1338
+ * BASE FIRST, which is the whole content of that compose: the container centres the spinner inside
1339
+ * the space it was given, and an app's own style still wins over it. Reversed, an app could never
1340
+ * override the centering and the override would fail silently.
1341
+ *
1342
+ * The container cannot be folded into the spinner instead: `alignItems`/`justifyContent` centre the
1343
+ * spinner within its box, and on the spinner they would centre its children, of which it has none.
1344
+ */
1345
+ dynamic foldActivityIndicatorProps(const dynamic &props) {
1346
+ dynamic out = props;
1347
+ dynamic container = dynamic::object();
1348
+ container["alignItems"] = "center";
1349
+ container["justifyContent"] = "center";
1350
+
1351
+ dynamic composed = dynamic::array(std::move(container));
1352
+ const dynamic *authored = props.get_ptr("style");
1353
+ if (authored != nullptr) composed.push_back(*authored);
1354
+ out["style"] = std::move(composed);
1355
+ return out;
1356
+ }
1357
+
1358
+ /**
1359
+ * `InputAccessoryView.js`'s `styles.container = {position: 'absolute'}`, composed as
1360
+ * `[props.style, styles.container]` — the platform's half LAST, so it wins over whatever the app
1361
+ * wrote. Every InputAccessoryView ever rendered is positioned absolutely; nothing here is
1362
+ * per-instance, which is what makes it the tag's rule and not composition.
1363
+ *
1364
+ * Only ever reached on iOS: the Android tag resolves to `VOID_COMPONENT`
1365
+ * (`component-names/index.android.ts`) and never reaches a payload build at all, so this needs no
1366
+ * platform gate of its own.
1367
+ */
1368
+ dynamic foldInputAccessoryViewProps(const dynamic &props) {
1369
+ dynamic container = dynamic::object();
1370
+ container["position"] = "absolute";
1371
+
1372
+ dynamic composed = dynamic::array();
1373
+ const dynamic *authored = props.get_ptr("style");
1374
+ if (authored != nullptr) composed.push_back(*authored);
1375
+ composed.push_back(std::move(container));
1376
+
1377
+ dynamic out = props;
1378
+ out["style"] = std::move(composed);
1379
+ return out;
1380
+ }
1381
+
1382
+ /** RN's `styles.header` z-index (`ScrollViewStickyHeader.js:318`). */
1383
+ constexpr double kStickyHeaderZIndex = 10;
1384
+
1385
+ /**
1386
+ * The settled translate the sticky machine hands this rule. RN's twin is
1387
+ * `passthroughAnimatedPropExplicitValues`, a whole style object; ours carries the one number that
1388
+ * object ever holds, so it does not borrow the name. Stripped here — no ViewConfig declares it.
1389
+ */
1390
+ constexpr const char *kStickyTranslateKey = "stickyTranslateY";
1391
+
1392
+ /**
1393
+ * The sticky header's wrapper (`ScrollViewStickyHeader.js:282-304`), and the LAST `payloadFold` this
1394
+ * codebase had.
1395
+ *
1396
+ * ITS THREE OUTPUTS SPLIT BY ORIGIN, which is the whole argument for the move: `zIndex` and
1397
+ * `collapsable` are constants of the wrapper — the platform's, in any app — while the translate is
1398
+ * live. The live one is live at SETTLE rate rather than frame rate (the smooth pin rides an
1399
+ * AnimatedProps leaf and never comes through here), and RN spells it as an ordinary PROP, so it
1400
+ * crosses as one instead of as a new opcode.
1401
+ *
1402
+ * COMPOSED OVER, not under, and that inverts the neighbouring rules. A pin is the entire point of
1403
+ * this element, so a header whose own style set a transform must not cancel it —
1404
+ * `foldActivityIndicatorProps` goes the other way because its base is a DEFAULT an app may override.
1405
+ * The distinction is whether the style is a default or a mechanism.
1406
+ *
1407
+ * `collapsable: false` is unconditional, as RN's literal JSX prop is. Yoga may flatten a view that
1408
+ * only groups children, and a flattened header has no view left to carry a transform — so the pin
1409
+ * would silently stop happening on exactly the headers that wrap nothing but their content.
1410
+ */
1411
+ dynamic foldStickyHeaderProps(const dynamic &props) {
1412
+ dynamic pin = dynamic::object();
1413
+ pin["zIndex"] = kStickyHeaderZIndex;
1414
+
1415
+ // Absent until the debounce first fires. Inventing a zero here would snap every header to the top
1416
+ // of its scroller on mount, so an unsettled machine contributes no transform at all and the app's
1417
+ // own survives.
1418
+ const dynamic *translate = props.get_ptr(kStickyTranslateKey);
1419
+ if (translate != nullptr && translate->isNumber()) {
1420
+ dynamic entry = dynamic::object();
1421
+ entry["translateY"] = translate->asDouble();
1422
+ pin["transform"] = dynamic::array(std::move(entry));
1423
+ }
1424
+
1425
+ dynamic composed = dynamic::array();
1426
+ const dynamic *authored = props.get_ptr("style");
1427
+ if (authored != nullptr) composed.push_back(*authored);
1428
+ composed.push_back(std::move(pin));
1429
+
1430
+ dynamic out = props;
1431
+ out.erase(kStickyTranslateKey);
1432
+ out["style"] = std::move(composed);
1433
+ out["collapsable"] = false;
1434
+ return out;
1435
+ }
1436
+
1437
+ /**
1438
+ * The native spinner's own props — RN's component body (`ActivityIndicator.js:99-118`) applied to
1439
+ * the node the app never names.
1440
+ *
1441
+ * SIZE IS TWO ANSWERS FROM ONE PROP. A named size gives a native enum AND a fixed pixel box; a
1442
+ * NUMBER gives only the box, and the key has to LEAVE rather than merely go unwritten — the native
1443
+ * enum takes "small"/"large" and nothing else, so a numeric `size` reaching it is a value it cannot
1444
+ * read.
1445
+ *
1446
+ * THE COLOUR DEFAULT IS WHERE "absent" AND "null" STOP BEING THE SAME THING. Android's default is
1447
+ * the theme, expressed by sending no key at all; Fabric's colour parser REJECTS a null, so writing
1448
+ * one is a crash rather than a shade. iOS defaults to RN's GRAY.
1449
+ */
1450
+ dynamic foldActivityIndicatorSpinnerProps(
1451
+ const dynamic &props,
1452
+ bool isAndroidProgressBar) {
1453
+ dynamic out = props;
1454
+
1455
+ // RN's own default when the app writes no size (`:72`).
1456
+ double box = kSpinnerSmallPx;
1457
+ const char *sizeEnum = "small";
1458
+ const dynamic *size = props.get_ptr("size");
1459
+ if (size != nullptr && size->isString() && size->asString() == "large") {
1460
+ box = kSpinnerLargePx;
1461
+ sizeEnum = "large";
1462
+ } else if (size != nullptr && size->isNumber()) {
1463
+ box = size->asDouble();
1464
+ sizeEnum = nullptr;
1465
+ }
1466
+ if (sizeEnum == nullptr) out.erase("size");
1467
+ else out["size"] = sizeEnum;
1468
+
1469
+ // REPLACES whatever style the node carried, as the JS fold this replaces did: the spinner's box is
1470
+ // the size translation's output and nothing else, and the app's own style lands on the centering
1471
+ // host one level up.
1472
+ dynamic sizeStyle = dynamic::object();
1473
+ sizeStyle["width"] = box;
1474
+ sizeStyle["height"] = box;
1475
+ out["style"] = std::move(sizeStyle);
1476
+
1477
+ // RN defaults both to true and spells it `!== false`, so only a literal false turns them off. A
1478
+ // tag has no destructuring default, which is why the rule carries it.
1479
+ out["animating"] = boolAt(props, "animating").value_or(true);
1480
+ out["hidesWhenStopped"] = boolAt(props, "hidesWhenStopped").value_or(true);
1481
+
1482
+ const dynamic *color = props.get_ptr("color");
1483
+ if (color == nullptr || !color->isString()) {
1484
+ if (isAndroidProgressBar) out.erase("color");
1485
+ else out["color"] = kSpinnerIosDefaultColor;
1486
+ }
1487
+
1488
+ // `ActivityIndicator.js:100-103`'s `androidProps = {styleAttr: 'Normal', indeterminate: true}`,
1489
+ // spread onto the native view unconditionally, Android only — neither key is app-facing.
1490
+ // `AndroidProgressBarNativeComponent`'s `indeterminate: boolean` carries no codegen default
1491
+ // (unlike `animating`), so an unset key falls back to whatever Java's own field default is, not
1492
+ // to a spinning loader.
1493
+ if (isAndroidProgressBar) {
1494
+ out["styleAttr"] = "Normal";
1495
+ out["indeterminate"] = true;
1496
+ }
1497
+ return out;
1498
+ }
1499
+
1500
+ /**
1501
+ * Button's own platform half, over and above the touchable's (`Button.js:350-382`). It runs AFTER
1502
+ * `foldPressableProps`, which is the order the JS composition always had.
1503
+ *
1504
+ * Four rules, and every one is a function of the tag alone:
1505
+ *
1506
+ * accessibilityRole pinned to "button" (`:372`), spelled as a literal on the element —
1507
+ * not forwarded from the app, and there is no way to opt out
1508
+ * importantForAccessibility "no" becomes "no-hide-descendants" (`:357-361`). Only that ONE value
1509
+ * moves; the label lives inside the button, so plain "no" would leave
1510
+ * the text separately reachable
1511
+ * touchSoundDisabled re-spelled `android_disableSound` (`:377`)
1512
+ * color stripped
1513
+ *
1514
+ * THE TWO STRIPS ARE THE HALF NOTHING ELSE CAN CATCH. Neither `touchSoundDisabled` nor `color` is
1515
+ * declared by any ViewConfig, so Fabric drops them without throwing, logging or painting
1516
+ * differently — the rename and the removal look identical to a screen whether or not they happen.
1517
+ *
1518
+ * `color` is erased from the PAYLOAD and never from the node: Button's derived folds (the label's
1519
+ * tint, the Android view style) read it off `propsOf(node)`, which this cannot reach. That
1520
+ * separation is what makes the strip safe here and unsafe one layer up.
1521
+ */
1522
+ /**
1523
+ * Button's TITLE, as it is rendered: uppercase on Android, verbatim everywhere else
1524
+ * (`Button.js:352-353`).
1525
+ *
1526
+ * `#ifdef ANDROID` rather than a view-name branch, and for the reason `decelerationRate`'s constants
1527
+ * take one: there is no name to read. A raw text commits as `RCTRawText` on both platforms — unlike
1528
+ * `Switch`/`AndroidSwitch`, where the two platforms genuinely are two Fabric components and the wire
1529
+ * already says which. So the Android arm is unreachable headless, like `android_ripple`'s, and that
1530
+ * is a recorded gap rather than a hidden one.
1531
+ *
1532
+ * ASCII-only, deliberately, and it is upstream's own behaviour rather than a shortcut: RN calls
1533
+ * JavaScript's `String.prototype.toUpperCase`, which is full Unicode, so a Cyrillic or Greek label
1534
+ * uppercases there and would not here. Left ASCII because the alternative is dragging ICU into the
1535
+ * engine for a label that is uppercased only on Android, and a wrong-case label is a cosmetic
1536
+ * difference on one platform rather than a broken control. Recorded so it is a decision.
1537
+ */
1538
+ dynamic foldButtonLabel(const dynamic &props) {
1539
+ #ifdef ANDROID
1540
+ const dynamic *text = props.get_ptr("text");
1541
+ if (text == nullptr || !text->isString()) return props;
1542
+ std::string upper = text->asString();
1543
+ for (char &character : upper)
1544
+ character = static_cast<char>(
1545
+ std::toupper(static_cast<unsigned char>(character)));
1546
+ dynamic out = props;
1547
+ out["text"] = std::move(upper);
1548
+ return out;
1549
+ #else
1550
+ return props;
1551
+ #endif
1552
+ }
1553
+
1554
+ /**
1555
+ * Button's own `disabled`, which is three questions where a plain touchable asks one:
1556
+ * `props.disabled ?? aria-disabled ?? accessibilityState.disabled` (`Button.js:331,337`).
1557
+ *
1558
+ * `??` and not `||`, which is the whole precedence: an EXPLICIT `disabled: false` beats an
1559
+ * `aria-disabled` that says otherwise, because the app's direct answer outranks the accessibility
1560
+ * hint. Presence decides at every step, so `false` is a real answer and stops the chain.
1561
+ *
1562
+ * Read off the AUTHORED bag, never off a folded one — see `foldButtonProps`.
1563
+ */
1564
+ std::optional<bool> buttonDisabled(const dynamic &authored) {
1565
+ const std::optional<bool> direct = boolAt(authored, "disabled");
1566
+ if (direct.has_value()) return direct;
1567
+ const std::optional<bool> aria = boolAt(authored, "aria-disabled");
1568
+ if (aria.has_value()) return aria;
1569
+ const dynamic *state = authored.get_ptr("accessibilityState");
1570
+ if (state == nullptr || !state->isObject()) return std::nullopt;
1571
+ return boolAt(*state, "disabled");
1572
+ }
1573
+
1574
+ /**
1575
+ * `authored` is `fabricProps`' own input — the bag BEFORE the aria fold, the id alias and the
1576
+ * pressable rule — and passing it is not a convenience.
1577
+ *
1578
+ * By the time this runs, `foldPressableProps` has folded `disabled` into `accessibilityState` and
1579
+ * ERASED the raw key, and `foldAriaProps` has folded `aria-disabled` into the same place. So the bag
1580
+ * this fold is handed can no longer tell an explicit `disabled: false` from an absent one, and the
1581
+ * `??` precedence above would collapse to whatever `accessibilityState` ended up holding. That is
1582
+ * Trap A, and it is the same correction the JS fold carried before the port, spelled
1583
+ * `projectionOf(propsOf(node))` for exactly this reason.
1584
+ */
1585
+ // Button's label styling, `Button.js:404-430` (`styles.text` / `styles.textDisabled`). NOT a mirror
1586
+ // of the JS constants that used to hold them: `resolveButtonTextStyle` and `buttonTextStyle` were
1587
+ // deleted in the same commit, so these are the only copy.
1588
+ constexpr double kButtonTextMargin = 8;
1589
+ // Guarded by the same `#ifdef` as their only use: `-Wunused-const-variable` is an ERROR here
1590
+ // (`-Werror`), so a platform constant compiled into the other platform's build does not merely sit
1591
+ // unused, it fails the build.
1592
+ #ifdef ANDROID
1593
+ constexpr const char *kAndroidButtonText = "white";
1594
+ constexpr const char *kAndroidButtonFontWeight = "500";
1595
+ constexpr const char *kAndroidDisabledText = "#a1a1a1";
1596
+ #else
1597
+ constexpr const char *kIosButtonBlue = "#007AFF";
1598
+ constexpr double kIosButtonFontSize = 18;
1599
+ constexpr const char *kIosDisabledText = "#cdcdcd";
1600
+ #endif
1601
+
1602
+ /**
1603
+ * Button's LABEL STYLE, on the `text` node the behavior builds (`Button.js:389-400`).
1604
+ *
1605
+ * THE ONLY RULE THAT READS AN ANCESTOR RATHER THAN A PARENT, and it has to: its inputs are the
1606
+ * BUTTON's `color` and `disabled`, and the button is this node's grandparent on iOS
1607
+ * (`button -> view -> text`) and its parent on Android. Asking for "the nearest button" rather than
1608
+ * "two up" is what makes one rule correct on both.
1609
+ *
1610
+ * NOT FOUND IS A REAL ANSWER and is left alone rather than defaulted: a `button-label-text` tag can
1611
+ * only be built by Button itself, so a miss means the tree was torn down around it mid-commit. A
1612
+ * default style would paint a stray label as a button.
1613
+ *
1614
+ * `color` tints the TEXT on iOS and the BUTTON on Android (`Button.js:318-324`), which is why the
1615
+ * tint is `#ifndef ANDROID` here and the same value lands on the view's style there. `disabled`
1616
+ * wins over the tint on both, because RN pushes the disabled colour after it.
1617
+ */
1618
+ dynamic foldButtonLabelStyle(
1619
+ const dynamic &props,
1620
+ const IAncestorLookup &ancestors) {
1621
+ if (ancestors.find == nullptr) return props;
1622
+ const dynamic *owner = ancestors.find(ancestors.context, "button");
1623
+ if (owner == nullptr) return props;
1624
+
1625
+ dynamic style = dynamic::object();
1626
+ style["textAlign"] = "center";
1627
+ style["margin"] = kButtonTextMargin;
1628
+ #ifdef ANDROID
1629
+ style["color"] = kAndroidButtonText;
1630
+ style["fontWeight"] = kAndroidButtonFontWeight;
1631
+ #else
1632
+ style["color"] = kIosButtonBlue;
1633
+ style["fontSize"] = kIosButtonFontSize;
1634
+ const dynamic *color = owner->get_ptr("color");
1635
+ if (color != nullptr && color->isString()) style["color"] = *color;
1636
+ #endif
1637
+
1638
+ const std::optional<bool> disabled = buttonDisabled(*owner);
1639
+ if (disabled.value_or(false)) {
1640
+ #ifdef ANDROID
1641
+ style["color"] = kAndroidDisabledText;
1642
+ #else
1643
+ style["color"] = kIosDisabledText;
1644
+ #endif
1645
+ }
1646
+
1647
+ dynamic out = props;
1648
+ out["style"] = std::move(style);
1649
+ // RN puts `disabled` on the Text as well (`Button.js:386`) — a real RCTText prop that Android's
1650
+ // accessibility layer reads, and a different thing from the greying above. ABSENT when none of the
1651
+ // three sources spoke, because that is what RN sends: `disabled={undefined}` is omitted, and
1652
+ // writing `false` would put a key on every button's label that upstream never emits.
1653
+ if (disabled.has_value()) out["disabled"] = *disabled;
1654
+ return out;
1655
+ }
1656
+
1657
+ #ifdef ANDROID
1658
+ // `TouchableNativeFeedback.js` via `Platform.Version >= 23` — foreground ripples need API 23.
1659
+ constexpr int kAndroidForegroundMinVersion = 23;
1660
+
1661
+ // `Button.js:394-437`, one constant per literal so a value cannot drift silently.
1662
+ constexpr int kAndroidButtonElevation = 4;
1663
+ constexpr int kAndroidDisabledElevation = 0;
1664
+ constexpr int kAndroidButtonBorderRadius = 2;
1665
+ constexpr const char *kAndroidButtonBlue = "#2196F3";
1666
+ constexpr const char *kAndroidDisabledBackground = "#dfdfdf";
1667
+
1668
+ /**
1669
+ * The running device's API level, and the one line of the Android branch a HOST build cannot have.
1670
+ *
1671
+ * `android_get_device_api_level` is the NDK's, so it exists when `__ANDROID__` is defined — which the
1672
+ * real toolchain sets and the test host's `-DANDROID` does not. That split is the point rather than
1673
+ * a workaround: the rule's LOGIC becomes testable in the Android arm of the test host
1674
+ * (`tests/CMakeLists.txt`, `SYMBIOTE_PLATFORM_ANDROID`) while the query itself stays the device's.
1675
+ *
1676
+ * The host answers with the minimum RN supports, so the arm exercises the branch an app on a modern
1677
+ * device takes. The other branch is reachable only on a device old enough to need it, which is where
1678
+ * it always was.
1679
+ */
1680
+ int androidApiLevel() {
1681
+ #ifdef __ANDROID__
1682
+ return android_get_device_api_level();
1683
+ #else
1684
+ return kAndroidForegroundMinVersion;
1685
+ #endif
1686
+ }
1687
+
1688
+ // RN's `TouchableNativeFeedback.SelectableBackground()` with no ripple radius, which is what its
1689
+ // body falls back to when the app passes no `background` (`:343-348`), and what Button's own view
1690
+ // gets because TNF clones onto it (`:339`).
1691
+ dynamic selectableItemBackground() {
1692
+ dynamic background = dynamic::object();
1693
+ background["type"] = "ThemeAttrAndroid";
1694
+ background["attribute"] = "selectableItemBackground";
1695
+ return background;
1696
+ }
1697
+ #endif
1698
+
1699
+ dynamic foldButtonProps(
1700
+ const dynamic &props,
1701
+ const dynamic &authored,
1702
+ bool hasPressListener) {
1703
+ dynamic out = props;
1704
+ out["accessibilityRole"] = "button";
1705
+
1706
+ // The touchable's three-leg `focusable`, over the one-leg answer `foldPressableProps` just wrote.
1707
+ // `usesTouchableFocusableRule` excludes `button` so that this can be the layer that decides it —
1708
+ // the same order the JS composition had, where the owner's fold ran after the touchable's.
1709
+ out["focusable"] = boolAt(props, "focusable").value_or(true) &&
1710
+ hasPressListener && !buttonDisabled(authored).value_or(false);
1711
+
1712
+ const dynamic *important = props.get_ptr("importantForAccessibility");
1713
+ if (important != nullptr && important->isString() &&
1714
+ important->asString() == "no") {
1715
+ out["importantForAccessibility"] = "no-hide-descendants";
1716
+ }
1717
+
1718
+ // AUTHORED, not `props` — `foldPressableProps` already ran `kTouchableFeedbackKeys` over `props`
1719
+ // (button is one of the three tags that rule covers), which erases `touchSoundDisabled` before
1720
+ // this ever sees it. Trap A again: a fold reading a key an earlier rule strips reads it gone.
1721
+ const dynamic *sound = authored.get_ptr("touchSoundDisabled");
1722
+ if (sound != nullptr) {
1723
+ out["android_disableSound"] = *sound;
1724
+ out.erase("touchSoundDisabled");
1725
+ }
1726
+
1727
+ // `Button.js:394-437`'s `styles.button`, which is `{}` on iOS in every combination — the reason
1728
+ // the wrapping view looked droppable there and the reason this half is the only one that moved.
1729
+ //
1730
+ // OVERWRITTEN rather than composed, because RN's Button declares no `style` prop at all: there is
1731
+ // nothing for it to compose with. `authored`, not `props`, for `color` and `disabled` — by the
1732
+ // time this runs `foldPressableProps` has folded `disabled` into `accessibilityState` and erased
1733
+ // the raw key, so reading it here would resolve through the state and lose RN's
1734
+ // `props.disabled ?? aria ?? state.disabled` precedence. Same correction `focusable` needed above.
1735
+ //
1736
+ // The background is the touchable's: TNF renders no view and CLONES onto Button's
1737
+ // `<View style={buttonStyles}>` (`TouchableNativeFeedback.js:339`), so on this platform THIS node
1738
+ // is that view, and it gets the theme's selectable background with no foreground.
1739
+ #ifdef ANDROID
1740
+ dynamic style = dynamic::object();
1741
+ style["elevation"] = kAndroidButtonElevation;
1742
+ style["borderRadius"] = kAndroidButtonBorderRadius;
1743
+ const std::string *color = stringAt(authored, "color");
1744
+ style["backgroundColor"] = color != nullptr ? *color : kAndroidButtonBlue;
1745
+ if (buttonDisabled(authored).value_or(false)) {
1746
+ style["elevation"] = kAndroidDisabledElevation;
1747
+ style["backgroundColor"] = kAndroidDisabledBackground;
1748
+ }
1749
+ out["style"] = std::move(style);
1750
+ out["nativeBackgroundAndroid"] = selectableItemBackground();
1751
+ #endif
1752
+
1753
+ out.erase("color");
1754
+ return out;
1755
+ }
1756
+
1757
+ // `foldIdAlias` IS GONE (2026-09-18). The rename lives in `routeProp` now — ONE implementation where
1758
+ // there were seven, and the reason it could not stay here is COVERAGE: a tag rule needs a non-empty
1759
+ // `tagName`, which only a node with a registered behavior has, so this rule never reached a plain
1760
+ // `<view>` or `<text>` and those are the commonest elements in any app. They were covered by the
1761
+ // adapters' own folds instead, three of which were separate implementations.
1762
+ //
1763
+ // Moving it to the write seam also settled a divergence rather than only removing copies: Vue, Solid
1764
+ // and Angular folded per key with no gate, so three of five adapters were already renaming `id` on
1765
+ // third-party views while React and Svelte were not. Everybody gets upstream's answer now.
1766
+ //
1767
+ // Contract: `core/engine/cpp/tests/js/id-alias-coverage.itest.ts`.
1768
+
1769
+ /**
1770
+ * The props the press MACHINE consumes and the host must never see.
1771
+ *
1772
+ * A wrapper dropped them by DESTRUCTURING — they went into `createPressHandlers` and were simply
1773
+ * absent from the object it spread onto its View. A tag has no destructure, so every one of them
1774
+ * rode into the payload as a key no ViewConfig declares: a wire slot, an interned string and a
1775
+ * hashed `RawProps` entry apiece, per pressable, per commit.
1776
+ *
1777
+ * `hitSlop` is deliberately NOT here and belongs to the same prop family — it is a real native View
1778
+ * prop Fabric reads. `pressRetentionOffset` beside it is not.
1779
+ */
1780
+ const std::array<const char *, 12> kPressableMachineKeys = {
1781
+ // RN's snapshot affordance (`Pressable.js:151,222`, `TouchableHighlight.js:61`). A JS-side
1782
+ // testing prop that no ViewConfig declares — listed here rather than erased by the one rule that
1783
+ // READS it, because all four tags carry the prop and only one paints from it.
1784
+ "testOnly_pressed",
1785
+ // Consumed below and replaced by the resolved `nativeBackgroundAndroid` /
1786
+ // `nativeForegroundAndroid`; the raw config is not a native prop.
1787
+ "android_ripple",
1788
+ "disabled",
1789
+ "cancelable",
1790
+ "delayLongPress",
1791
+ "minPressDuration",
1792
+ "unstable_pressDelay",
1793
+ "pressRetentionOffset",
1794
+ "delayHoverIn",
1795
+ "delayHoverOut",
1796
+ // Pressability.js:749-757 — gates a JS-side `SoundManager.playTouchSound()` call at release
1797
+ // time. No ViewConfig declares it; it never reaches a native view on any platform.
1798
+ "android_disableSound",
1799
+ // Pressability.js:479 — read by `onResponderGrant`'s return value alone. Pressable.js
1800
+ // destructures it out of props before spreading the rest onto the View, so it never reaches a
1801
+ // native view upstream either.
1802
+ "blockNativeResponder",
1803
+ };
1804
+
1805
+ /**
1806
+ * The Android native-feedback background, from the `android_ripple` config. `TouchableNativeFeedback`
1807
+ * is where the shape comes from; RN's own `Pressable` spreads `useAndroidRippleForView`'s `viewProps`
1808
+ * onto its OWN View (`Pressable.js:251`), which is why one node carries it and no inner view is
1809
+ * needed.
1810
+ *
1811
+ * The colour stays a STRING, as the JS this replaces left it: `nativeBackgroundAndroid` is a nested
1812
+ * object and the payload's colour processing is keyed on top-level names, so converting here would be
1813
+ * a change to the rule rather than a move of it. Android resolves the string; `null` is its
1814
+ * documented "no tint".
1815
+ *
1816
+ * STILL MISSING, and it always was: RN also dispatches `Commands.hotspotUpdate(x, y)` on
1817
+ * pressIn/pressMove and `Commands.setPressed` on pressIn/pressOut, which is what makes the ripple
1818
+ * originate at the touch point. Neither the old wrapper nor the behavior ever sent them.
1819
+ */
1820
+ #ifdef ANDROID
1821
+ void applyAndroidRipple(dynamic &out, const dynamic &config) {
1822
+ dynamic background = dynamic::object();
1823
+ background["type"] = "RippleAndroid";
1824
+ const std::string *color = stringAt(config, "color");
1825
+ background["color"] = color != nullptr ? dynamic(*color) : dynamic(nullptr);
1826
+ background["borderless"] = boolAt(config, "borderless").value_or(false);
1827
+ const dynamic *radius = config.get_ptr("radius");
1828
+ if (radius != nullptr && radius->isNumber()) background["rippleRadius"] = *radius;
1829
+
1830
+ out[boolAt(config, "foreground").value_or(false) ? "nativeForegroundAndroid"
1831
+ : "nativeBackgroundAndroid"] =
1832
+ std::move(background);
1833
+ }
1834
+ #endif
1835
+
1836
+ /**
1837
+ * Pressable's user-agent half, applied to the bag on its way to the payload.
1838
+ *
1839
+ * Every line is `Pressable.js` and none of it is any app's, any framework's or any instance's:
1840
+ *
1841
+ * :257 `disabled` reaches a screen reader ONLY as `accessibilityState.disabled` — it is not a
1842
+ * native View prop at all. Lowering once dropped this fold and nothing went red, because
1843
+ * press suppression reads the node's own prop: the button behaved correctly and announced
1844
+ * itself as enabled.
1845
+ * :252 accessible unless the app opts OUT, and `!== false` rather than `?? true` — only a
1846
+ * literal false opts out, an explicit `undefined` still reads as accessible.
1847
+ * :258 the same shape for `focusable`, in its PLAIN form with no press-handler or disabled leg. A
1848
+ * Touchable composing this tag has already resolved its own three-leg formula and passes the
1849
+ * answer down as `focusable`, which `!== false` leaves alone — that is how the two compose
1850
+ * without either knowing about the other.
1851
+ * :340 `collapsable={false}`, unconditional, on the BARE `Pressable` only. TouchableOpacity gets
1852
+ * the same value already, but from a DIFFERENT mechanism — binding its Animated opacity
1853
+ * (`touchable-opacity.ts`'s `setAnimatedBehaviorStyle` at attach) forces it as a side effect,
1854
+ * matching RN's own natively-driven Animated views. `isBarePressable` covers the one tag
1855
+ * that has neither an Animated binding nor this line: a plain Pressable with no fade.
1856
+ */
1857
+ dynamic foldPressableProps(
1858
+ const dynamic &props,
1859
+ bool isTouchableFeedback,
1860
+ bool isTouchableFocusable,
1861
+ bool hasPressListener,
1862
+ bool isBarePressable) {
1863
+ dynamic out = props;
1864
+ if (isBarePressable) out["collapsable"] = false;
1865
+
1866
+ // Read BEFORE the machine keys are erased, and `!= null` rather than truthiness: an explicit
1867
+ // `disabled: false` is a real announcement, so it is presence and not value that decides.
1868
+ const std::optional<bool> disabled = boolAt(props, "disabled");
1869
+ if (disabled.has_value()) {
1870
+ const dynamic *authored = props.get_ptr("accessibilityState");
1871
+ dynamic state =
1872
+ authored != nullptr && authored->isObject() ? *authored : dynamic::object();
1873
+ state["disabled"] = *disabled;
1874
+ out["accessibilityState"] = std::move(state);
1875
+ }
1876
+
1877
+ // Off Android there is nothing to resolve — `rippleProps` returned undefined there — and the
1878
+ // config is simply erased with the rest of the machine's keys below.
1879
+ #ifdef ANDROID
1880
+ const dynamic *ripple = props.get_ptr("android_ripple");
1881
+ if (ripple != nullptr && ripple->isObject()) applyAndroidRipple(out, *ripple);
1882
+ #endif
1883
+
1884
+ for (const char *key : kPressableMachineKeys) out.erase(key);
1885
+ if (isTouchableFeedback) {
1886
+ for (const char *key : kTouchableFeedbackKeys) out.erase(key);
1887
+ }
1888
+
1889
+ out["accessible"] = boolAt(props, "accessible").value_or(true);
1890
+ // `disabled` READ OFF `props`, the untouched input, and never off `out` — the erase loop above has
1891
+ // already taken the raw key out of `out`, so reading it there would resolve every disabled
1892
+ // touchable as focusable. That is a focus-order bug visible on a TV remote and in no test that
1893
+ // reads props; the JS fold this replaces carried the same correction, spelled `propOf(node, ...)`.
1894
+ out["focusable"] = isTouchableFocusable
1895
+ ? boolAt(props, "focusable").value_or(true) && hasPressListener &&
1896
+ !boolAt(props, "disabled").value_or(false)
1897
+ : boolAt(props, "focusable").value_or(true);
1898
+ return out;
1899
+ }
1900
+
1901
+ /**
1902
+ * `TouchableNativeFeedback.js:349-390`, verbatim and in RN's own order. A CLOSED list, not a
1903
+ * passthrough: RN clones exactly these and nothing else, and it clones them WHATEVER their value —
1904
+ * `cloneElement` assigns every key of its config, so an owner with no `accessibilityLabel` CLEARS
1905
+ * the child's. Writing the key unconditionally is what reproduces that; `fabricProps` drops an
1906
+ * undefined on its way out, which is the same erasure the JS fold relied on.
1907
+ */
1908
+ const std::array<const char *, 18> kNativeFeedbackClonedKeys = {
1909
+ "accessibilityHint",
1910
+ "accessibilityLanguage",
1911
+ "accessibilityLabel",
1912
+ "accessibilityRole",
1913
+ "accessibilityActions",
1914
+ "accessibilityValue",
1915
+ "importantForAccessibility",
1916
+ "accessibilityViewIsModal",
1917
+ "accessibilityLiveRegion",
1918
+ "accessibilityElementsHidden",
1919
+ "hasTVPreferredFocus",
1920
+ "hitSlop",
1921
+ "nextFocusDown",
1922
+ "nextFocusForward",
1923
+ "nextFocusLeft",
1924
+ "nextFocusRight",
1925
+ "nextFocusUp",
1926
+ "testID",
1927
+ };
1928
+
1929
+ /**
1930
+ * `TouchableWithoutFeedback.js:281` — copied ONLY when set, which is the whole split from TNF's
1931
+ * unconditional clone. Reproducing the difference matters: an owner with no `testID` leaves the
1932
+ * child's standing here and erases it there, and RN's two bodies genuinely differ that way.
1933
+ */
1934
+ const std::array<const char *, 10> kWithoutFeedbackWhenSetKeys = {
1935
+ "accessibilityActions",
1936
+ "accessibilityHint",
1937
+ "accessibilityLanguage",
1938
+ "accessibilityIgnoresInvertColors",
1939
+ "accessibilityLabel",
1940
+ "accessibilityRole",
1941
+ "accessibilityValue",
1942
+ "accessibilityViewIsModal",
1943
+ "hitSlop",
1944
+ "testID",
1945
+ };
1946
+
1947
+ /**
1948
+ * `:253-276`, assigned whatever their value. Three of them are in RN's when-set list TOO, and the
1949
+ * later conditional copy can only re-assign what the aria fold already resolved — so they belong
1950
+ * here, where they clear.
1951
+ */
1952
+ const std::array<const char *, 3> kWithoutFeedbackAlwaysKeys = {
1953
+ "accessibilityElementsHidden",
1954
+ "accessibilityLiveRegion",
1955
+ "importantForAccessibility",
1956
+ };
1957
+
1958
+ bool usesCloneOntoChildRule(const char *ownerTag) {
1959
+ if (ownerTag == nullptr) return false;
1960
+ return std::strcmp(ownerTag, "touchable-native-feedback") == 0 ||
1961
+ std::strcmp(ownerTag, "touchable-without-feedback") == 0;
1962
+ }
1963
+
1964
+ /**
1965
+ * `cloneElement(child, {…})` as a DESCENDANT rule: the child's own bag first, the owner's clone list
1966
+ * over it.
1967
+ *
1968
+ * This is the first rule keyed on the PARENT's tag rather than on the node's own, and the reason is
1969
+ * structural rather than convenient — see `IOwner`. Both touchables render no view, so the owner is
1970
+ * an anchor whose props reach Fabric nowhere else; the clone is not a decoration on the child, it is
1971
+ * the entire primitive.
1972
+ *
1973
+ * THE OWNER'S ARIA FOLD RUNS HERE, over the owner's bag. `fabricProps` folds aria for the node being
1974
+ * committed, and these props are on a node that is never committed — so without this an
1975
+ * `aria-label` on a TNF would reach nothing at all.
1976
+ */
1977
+ dynamic foldCloneOntoChild(
1978
+ const dynamic &props, const IOwner &owner, bool isNativeFeedback) {
1979
+ if (owner.props == nullptr) return props;
1980
+ const bool foldsAria = hasAriaAlias(*owner.props);
1981
+ const dynamic ariaFolded = foldsAria ? foldAriaProps(*owner.props) : dynamic();
1982
+ const dynamic &source = foldsAria ? ariaFolded : *owner.props;
1983
+
1984
+ dynamic out = props;
1985
+ if (isNativeFeedback) {
1986
+ for (const char *key : kNativeFeedbackClonedKeys) {
1987
+ const dynamic *value = source.get_ptr(key);
1988
+ if (value == nullptr) out.erase(key);
1989
+ else out[key] = *value;
1990
+ }
1991
+ } else {
1992
+ for (const char *key : kWithoutFeedbackWhenSetKeys) {
1993
+ const dynamic *value = source.get_ptr(key);
1994
+ if (value != nullptr) out[key] = *value;
1995
+ }
1996
+ for (const char *key : kWithoutFeedbackAlwaysKeys) {
1997
+ const dynamic *value = source.get_ptr(key);
1998
+ if (value == nullptr) out.erase(key);
1999
+ else out[key] = *value;
2000
+ }
2001
+ }
2002
+
2003
+ // `:369-372` / `:253-276`. All four are the OWNER'S, which is the point: the child never saw any
2004
+ // of them, and `focusable`'s middle leg is a listener the owner owns and no bag can carry.
2005
+ const std::optional<bool> disabled = boolAt(source, "disabled");
2006
+ out["accessible"] = boolAt(source, "accessible").value_or(true);
2007
+ out["focusable"] = boolAt(source, "focusable").value_or(true) &&
2008
+ owner.hasPressListener && !disabled.value_or(false);
2009
+ // A STRING or nothing, which is the narrowing `stringOr` did — and "nothing" is an ERASE rather
2010
+ // than a null, because a cleared clone key reaches Fabric by being absent from the payload.
2011
+ const std::string *nativeID = stringAt(source, "nativeID");
2012
+ if (nativeID == nullptr) out.erase("nativeID");
2013
+ else out["nativeID"] = *nativeID;
2014
+
2015
+ // `resolveDisabledAccessibilityState`: a present `disabled` MERGES over the owner's authored
2016
+ // state, an absent one passes that state through untouched. `!= null` and not truthiness — an
2017
+ // explicit `disabled: false` is a real announcement, the same reading `foldPressableProps` takes.
2018
+ const dynamic *authoredState = source.get_ptr("accessibilityState");
2019
+ if (disabled.has_value()) {
2020
+ dynamic state = authoredState != nullptr && authoredState->isObject()
2021
+ ? *authoredState
2022
+ : dynamic::object();
2023
+ state["disabled"] = *disabled;
2024
+ out["accessibilityState"] = std::move(state);
2025
+ } else if (authoredState == nullptr) {
2026
+ out.erase("accessibilityState");
2027
+ } else {
2028
+ out["accessibilityState"] = *authoredState;
2029
+ }
2030
+
2031
+ // `:343-348` + `:402`. `getBackgroundProp` returns null off Android, so nothing is spread there —
2032
+ // and that branch is a compile-time one for the reason `android_ripple` already is: both
2033
+ // touchables commit an ordinary `RCTView`, so no component name can tell the platforms apart.
2034
+ //
2035
+ // The dict itself is the APP'S — `TouchableNativeFeedback.Ripple(...)` and its three siblings are
2036
+ // pure factories the app calls, so the rule only picks the default and the SLOT. That is why this
2037
+ // is not `applyAndroidRipple`, which builds `android_ripple`'s dict from scratch: here there is
2038
+ // nothing to build.
2039
+ #ifdef ANDROID
2040
+ if (isNativeFeedback) {
2041
+ const dynamic *authored = source.get_ptr("background");
2042
+ dynamic background = authored != nullptr && authored->isObject()
2043
+ ? *authored
2044
+ : selectableItemBackground();
2045
+ // `canUseNativeForeground()` — RN's own guard, and `Platform.Version` on Android IS the API
2046
+ // level, so the JS check and this one read the same number.
2047
+ out[boolAt(source, "useForeground").value_or(false) &&
2048
+ androidApiLevel() >= kAndroidForegroundMinVersion
2049
+ ? "nativeForegroundAndroid"
2050
+ : "nativeBackgroundAndroid"] = std::move(background);
2051
+ }
2052
+ #endif
2053
+ return out;
2054
+ }
2055
+
2056
+ // The names Image CONSUMES rather than forwards. Every one is a W3C spelling or a size alias, and
2057
+ // none is a Fabric prop — leaving one in the payload is how a reader concludes the rule ran when it
2058
+ // did not. `source` is absent on purpose: it is consumed and then WRITTEN BACK, resolved.
2059
+ const std::array<const char *, 8> kImageConsumedKeys = {
2060
+ "src",
2061
+ "srcSet",
2062
+ "crossOrigin",
2063
+ "referrerPolicy",
2064
+ "alt",
2065
+ "width",
2066
+ "height",
2067
+ "loadingIndicatorSource",
2068
+ };
2069
+
2070
+ /**
2071
+ * The HTTP headers the two W3C aliases contribute to every source
2072
+ * (`ImageSourceUtils.js:40-46`). Empty is a real answer and is still attached on the `src`/`srcSet`
2073
+ * branches, because upstream pushes `{uri, headers, ...}` unconditionally there.
2074
+ */
2075
+ dynamic imageHeaders(const dynamic &props) {
2076
+ dynamic headers = dynamic::object();
2077
+ const std::string *crossOrigin = stringAt(props, "crossOrigin");
2078
+ // `anonymous` is the default browser behaviour and contributes nothing — upstream checks for
2079
+ // `use-credentials` specifically.
2080
+ if (crossOrigin != nullptr && *crossOrigin == "use-credentials") {
2081
+ headers["Access-Control-Allow-Credentials"] = "true";
2082
+ }
2083
+ const std::string *referrerPolicy = stringAt(props, "referrerPolicy");
2084
+ if (referrerPolicy != nullptr) headers["Referrer-Policy"] = *referrerPolicy;
2085
+ return headers;
2086
+ }
2087
+
2088
+ /** Copy the size aliases onto a source entry, as upstream does for the `src` and `srcSet` shapes. */
2089
+ void addSizeHints(dynamic &entry, const dynamic &props) {
2090
+ const dynamic *width = props.get_ptr("width");
2091
+ if (width != nullptr && width->isNumber()) entry["width"] = *width;
2092
+ const dynamic *height = props.get_ptr("height");
2093
+ if (height != nullptr && height->isNumber()) entry["height"] = *height;
2094
+ }
2095
+
2096
+ /**
2097
+ * `srcSet` expanded into scaled sources — `ImageSourceUtils.js:48-79`.
2098
+ *
2099
+ * `src` fills the 1x slot ONLY when the set omits it: native picks by screen scale, so a missing 1x
2100
+ * is a blank image on a non-retina device and a duplicated one is undefined behaviour. A scale
2101
+ * token that is not `<n>x` is SKIPPED rather than guessed at — guessing fetches the wrong asset at
2102
+ * the wrong density, silently.
2103
+ */
2104
+ dynamic expandSrcSet(
2105
+ const std::string &srcSet,
2106
+ const dynamic &props,
2107
+ const dynamic &headers) {
2108
+ dynamic sources = dynamic::array();
2109
+ bool useSrcForDefaultScale = true;
2110
+
2111
+ for (size_t at = 0; at <= srcSet.size();) {
2112
+ const size_t end = std::min(srcSet.find(", ", at), srcSet.size());
2113
+ const std::string entry = srcSet.substr(at, end - at);
2114
+ at = end + 2;
2115
+ if (entry.empty()) continue;
2116
+
2117
+ const size_t space = entry.find(' ');
2118
+ const std::string uri = entry.substr(0, space);
2119
+ const std::string token =
2120
+ space == std::string::npos ? "1x" : entry.substr(space + 1);
2121
+ if (token.empty() || token.back() != 'x') continue;
2122
+ char *parsedTo = nullptr;
2123
+ const long scale = std::strtol(token.c_str(), &parsedTo, 10);
2124
+ // `strtol` stops at the `x`, so a token that parsed nothing has no digits at all.
2125
+ if (parsedTo == token.c_str()) continue;
2126
+ if (scale == 1) useSrcForDefaultScale = false;
2127
+
2128
+ dynamic source = dynamic::object();
2129
+ source["uri"] = uri;
2130
+ source["scale"] = static_cast<double>(scale);
2131
+ addSizeHints(source, props);
2132
+ source["headers"] = headers;
2133
+ sources.push_back(std::move(source));
2134
+ }
2135
+
2136
+ const std::string *src = stringAt(props, "src");
2137
+ if (useSrcForDefaultScale && src != nullptr) {
2138
+ dynamic source = dynamic::object();
2139
+ source["uri"] = *src;
2140
+ source["scale"] = 1.0;
2141
+ addSizeHints(source, props);
2142
+ source["headers"] = headers;
2143
+ sources.push_back(std::move(source));
2144
+ }
2145
+ return sources;
2146
+ }
2147
+
2148
+ /**
2149
+ * Which of `srcSet` / `src` / `source` native is actually shown — `ImageSourceUtils.js:47-89`, in
2150
+ * that precedence. An app migrating from the web writes `src` and would otherwise see nothing paint.
2151
+ *
2152
+ * `source` ARRIVES ALREADY RESOLVED: `routeProp` ran it through Metro's asset registry and
2153
+ * normalised it to an array on the way in (`image-source-write.ts`), because that registry is
2154
+ * JavaScript and there is none here. So this only chooses and decorates.
2155
+ */
2156
+ dynamic resolveImageSources(const dynamic &props) {
2157
+ const dynamic headers = imageHeaders(props);
2158
+
2159
+ const std::string *srcSet = stringAt(props, "srcSet");
2160
+ if (srcSet != nullptr) return expandSrcSet(*srcSet, props, headers);
2161
+
2162
+ const std::string *src = stringAt(props, "src");
2163
+ if (src != nullptr) {
2164
+ dynamic source = dynamic::object();
2165
+ source["uri"] = *src;
2166
+ addSizeHints(source, props);
2167
+ source["headers"] = headers;
2168
+ return dynamic::array(std::move(source));
2169
+ }
2170
+
2171
+ const dynamic *resolved = props.get_ptr("source");
2172
+ if (resolved == nullptr || !resolved->isArray()) return dynamic::array();
2173
+
2174
+ // A header-decorated SINGLE object source gets them merged in (`:84`), so the aliases work on the
2175
+ // RN spelling too. The multi-entry and asset-id shapes pass through untouched, as upstream leaves
2176
+ // them.
2177
+ if (!headers.empty() && resolved->size() == 1 && resolved->at(0).isObject() &&
2178
+ stringAt(resolved->at(0), "uri") != nullptr) {
2179
+ dynamic only = resolved->at(0);
2180
+ only["headers"] = headers;
2181
+ return dynamic::array(std::move(only));
2182
+ }
2183
+ return *resolved;
2184
+ }
2185
+
2186
+ /**
2187
+ * Image's user-agent half.
2188
+ *
2189
+ * The one rule that does NOT move whole, and the split is worth understanding: every line here is a
2190
+ * function of the tag, except the asset lookup that turns `require('./logo.png')` into a uri. That
2191
+ * one asks Metro's registry — a JS table populated at bundle time — so it happens at WRITE time
2192
+ * instead, the same seam and the same argument as `structured-style.ts`. By the time this runs, the
2193
+ * bag already holds resolved sources.
2194
+ */
2195
+ dynamic foldImageProps(const dynamic &props, bool ariaHiddenIsTrue) {
2196
+ dynamic out = props;
2197
+ out["source"] = resolveImageSources(props);
2198
+
2199
+ // `ImageProps.js:195,202` — the size aliases are STYLE, not props, and an explicit style key wins.
2200
+ // RN spells it `{width, height}, ...style`, so they go UNDER.
2201
+ const dynamic *width = props.get_ptr("width");
2202
+ const dynamic *height = props.get_ptr("height");
2203
+ if (width != nullptr || height != nullptr) {
2204
+ dynamic sizes = dynamic::object();
2205
+ if (width != nullptr) sizes["width"] = *width;
2206
+ if (height != nullptr) sizes["height"] = *height;
2207
+ dynamic composed = dynamic::array(std::move(sizes));
2208
+ const dynamic *authored = props.get_ptr("style");
2209
+ if (authored != nullptr) composed.push_back(*authored);
2210
+ out["style"] = std::move(composed);
2211
+ }
2212
+
2213
+ // RN accepts these two as style keys as well as props; reading only the prop drops a style
2214
+ // authors legitimately write.
2215
+ const dynamic *style = props.get_ptr("style");
2216
+ if (out.get_ptr("resizeMode") == nullptr && style != nullptr) {
2217
+ const dynamic *fromStyle = lastStyleValue(*style, "resizeMode");
2218
+ if (fromStyle != nullptr) out["resizeMode"] = *fromStyle;
2219
+ }
2220
+ if (out.get_ptr("tintColor") == nullptr && style != nullptr) {
2221
+ const dynamic *fromStyle = lastStyleValue(*style, "tintColor");
2222
+ if (fromStyle != nullptr) out["tintColor"] = *fromStyle;
2223
+ }
2224
+
2225
+ // `alt` is the accessibility text: it sets the label AND marks the image accessible, which is
2226
+ // what puts it in the reader's order at all. An explicit label wins; an image with NO alt is
2227
+ // left out of the order entirely, since a decorative image announcing itself is noise a
2228
+ // screen-reader user cannot skip. The two platforms diverge on `aria-hidden`, though: iOS's
2229
+ // formula is `ariaHidden !== true && (alt !== undefined ? true : props.accessible)`
2230
+ // (`Image.ios.js`) — an explicit `aria-hidden` overrides alt's own accessible-true. Android's
2231
+ // `alt` sets `accessible = true` unconditionally (`Image.android.js:272-273`); `aria-hidden`
2232
+ // there only ever reaches `importantForAccessibility` (:295-297), never `accessible`.
2233
+ const std::string *alt = stringAt(props, "alt");
2234
+ if (alt != nullptr) {
2235
+ if (out.get_ptr("accessibilityLabel") == nullptr) {
2236
+ out["accessibilityLabel"] = *alt;
2237
+ }
2238
+ #ifdef ANDROID
2239
+ out["accessible"] = true;
2240
+ #else
2241
+ out["accessible"] = !ariaHiddenIsTrue;
2242
+ #endif
2243
+ }
2244
+
2245
+ // Android's loading indicator is a bare uri STRING under a different name, not the array shape
2246
+ // the main source uses. Sending the array paints no placeholder and says nothing.
2247
+ const dynamic *indicator = props.get_ptr("loadingIndicatorSource");
2248
+ if (indicator != nullptr && indicator->isArray() && indicator->size() > 0 &&
2249
+ indicator->at(0).isObject()) {
2250
+ const std::string *uri = stringAt(indicator->at(0), "uri");
2251
+ if (uri != nullptr) out["loadingIndicatorSrc"] = *uri;
2252
+ }
2253
+
2254
+ // ImageViewNativeComponent.js:138 — `defaultSource: { process: resolveAssetSource }`, the
2255
+ // SINGULAR resolver, unlike `source` (which the JS component itself normalizes to an array
2256
+ // before any prop reaches native). The engine's own resolver wraps every source-shaped prop into
2257
+ // an array uniformly (`image-source-write.ts`), so this name alone needs unwrapping back to the
2258
+ // bare object native's view manager expects — a `ReadableArray` where it wants a `ReadableMap`.
2259
+ const dynamic *defaultSource = props.get_ptr("defaultSource");
2260
+ if (defaultSource != nullptr && defaultSource->isArray() &&
2261
+ defaultSource->size() > 0) {
2262
+ out["defaultSource"] = defaultSource->at(0);
2263
+ }
2264
+
2265
+ for (const char *key : kImageConsumedKeys) out.erase(key);
2266
+ return out;
2267
+ }
2268
+
2269
+ /** RN rounds the iOS background pill to this radius when `ios_backgroundColor` is set. */
2270
+ constexpr double kIosSwitchBackgroundRadius = 16;
2271
+
2272
+ /**
2273
+ * Switch's user-agent half — and every authored name it reads is INVENTED.
2274
+ *
2275
+ * `trackColor`, `thumbColor` and `ios_backgroundColor` are not Fabric props. RN's Switch view
2276
+ * declares `onTintColor`/`tintColor` on iOS and `trackColorFor*`/`trackTintColor` on Android, plus
2277
+ * `thumbTintColor` on both, and `ios_backgroundColor` is not a prop at all — it is a STYLE
2278
+ * (`Switch.js:266-276`, a background plus a 16pt radius so the pill shows through the track).
2279
+ *
2280
+ * A wrapper body took those per-platform NAMES from an adapter-supplied table. A tag has no adapter
2281
+ * to ask, so the platform branch is here, once, instead of in five adapters.
2282
+ *
2283
+ * WRITES ONLY WHAT IT RESOLVES: an absent authored colour leaves its native name unset rather than
2284
+ * writing a null, which is what the payload builder would otherwise send as an explicit reset.
2285
+ */
2286
+ dynamic foldSwitchProps(const dynamic &props, bool isAndroidSwitch) {
2287
+ dynamic out = props;
2288
+
2289
+ // `value === true`, not a passthrough (`Switch.js:242,280`): the native prop is a boolean, and an
2290
+ // authored `undefined` must read as OFF. An uncontrolled switch painting ON is the worse failure.
2291
+ const bool isOn = boolAt(props, "value").value_or(false);
2292
+
2293
+ const dynamic *trackColor = props.get_ptr("trackColor");
2294
+ const std::string *trackFalse = nullptr;
2295
+ const std::string *trackTrue = nullptr;
2296
+ if (trackColor != nullptr && trackColor->isObject()) {
2297
+ trackFalse = stringAt(*trackColor, "false");
2298
+ trackTrue = stringAt(*trackColor, "true");
2299
+ }
2300
+
2301
+ const std::optional<bool> disabled = boolAt(props, "disabled");
2302
+
2303
+ if (isAndroidSwitch) {
2304
+ // A DIFFERENT NATIVE COMPONENT WITH A DIFFERENT PROP SURFACE (`Switch.js:240-249`), which is
2305
+ // why the branch reads the view name rather than a compile-time macro: `AndroidSwitch` declares
2306
+ // `on` and `enabled`, and knows neither `value` nor `disabled`. Sending the iOS names here
2307
+ // painted an Android switch from nothing and left it impossible to disable.
2308
+ out["on"] = isOn;
2309
+ out.erase("value");
2310
+
2311
+ // `:232` — the a11y state is the FALLBACK for `disabled`, so an app that only spells
2312
+ // `accessibilityState.disabled` still gets a switch it cannot toggle.
2313
+ const dynamic *authoredState = props.get_ptr("accessibilityState");
2314
+ const std::optional<bool> stateDisabled =
2315
+ authoredState != nullptr && authoredState->isObject()
2316
+ ? boolAt(*authoredState, "disabled")
2317
+ : std::nullopt;
2318
+ // `_disabled = disabled ?? accessibilityState?.disabled` (`:232-233`) — stays UNDEFINED, not
2319
+ // `false`, when neither is authored. Collapsing straight to a bool here (as an earlier version
2320
+ // did) made `enabled` come out right but broke the write-back check below for the commonest
2321
+ // case: an ordinary switch with no `disabled` and no `accessibilityState` at all.
2322
+ const std::optional<bool> resolvedDisabled =
2323
+ disabled.has_value() ? disabled : stateDisabled;
2324
+ out["enabled"] = resolvedDisabled.value_or(false) != true;
2325
+ out.erase("disabled");
2326
+
2327
+ // `:235-238` — `_disabled !== accessibilityState?.disabled`. With NEITHER authored both sides
2328
+ // are undefined and RN sends no `accessibilityState` at all; inventing `{disabled: false}` here
2329
+ // would be a payload a device never produces. Merged rather than replaced when it DOES differ —
2330
+ // an authored `busy` survives.
2331
+ if (resolvedDisabled != stateDisabled) {
2332
+ dynamic state = authoredState != nullptr && authoredState->isObject()
2333
+ ? *authoredState
2334
+ : dynamic::object();
2335
+ state["disabled"] = *resolvedDisabled;
2336
+ out["accessibilityState"] = std::move(state);
2337
+ }
2338
+
2339
+ if (trackFalse != nullptr) out["trackColorForFalse"] = *trackFalse;
2340
+ if (trackTrue != nullptr) out["trackColorForTrue"] = *trackTrue;
2341
+ const std::string *tint = isOn ? trackTrue : trackFalse;
2342
+ if (tint != nullptr) out["trackTintColor"] = *tint;
2343
+ // `:230` destructures the iOS colour names out of what reaches this view. They are keys it does
2344
+ // not declare.
2345
+ out.erase("onTintColor");
2346
+ out.erase("tintColor");
2347
+ } else {
2348
+ out["value"] = isOn;
2349
+ if (disabled.has_value()) out["disabled"] = *disabled;
2350
+ else out.erase("disabled");
2351
+
2352
+ if (trackTrue != nullptr) out["onTintColor"] = *trackTrue;
2353
+ if (trackFalse != nullptr) out["tintColor"] = *trackFalse;
2354
+
2355
+ // THE iOS STYLE COMPOSITION, and it is iOS's alone — `:266-276` is the `else` branch, so
2356
+ // Android's style is the app's untouched and `ios_backgroundColor` is not read there at all.
2357
+ //
2358
+ // The slot takes an ARRAY, which `addStyle` flattens in order, so this reproduces RN's nested
2359
+ // `StyleSheet.compose` exactly: `alignSelf` UNDER the app's style (an app that writes
2360
+ // `alignSelf: 'stretch'` still wins), the pill OVER it.
2361
+ const dynamic *authoredStyle = props.get_ptr("style");
2362
+ const std::string *iosBackground = stringAt(props, "ios_backgroundColor");
2363
+ dynamic composed = dynamic::array();
2364
+ dynamic intrinsic = dynamic::object();
2365
+ // `:267` — a stock iOS switch keeps its intrinsic width instead of stretching to its
2366
+ // container's cross axis. Omitting it made every one of ours stretch.
2367
+ intrinsic["alignSelf"] = "flex-start";
2368
+ composed.push_back(std::move(intrinsic));
2369
+ if (authoredStyle != nullptr) composed.push_back(*authoredStyle);
2370
+ if (iosBackground != nullptr) {
2371
+ dynamic pill = dynamic::object();
2372
+ pill["backgroundColor"] = *iosBackground;
2373
+ pill["borderRadius"] = kIosSwitchBackgroundRadius;
2374
+ composed.push_back(std::move(pill));
2375
+ }
2376
+ out["style"] = std::move(composed);
2377
+ }
2378
+
2379
+ // `:255,293` — both platforms, and a `??` rather than an override: an app that calls its switch a
2380
+ // checkbox keeps its own answer. Without this a screen reader announces the control as a plain
2381
+ // view, with nothing visual to notice.
2382
+ if (out.get_ptr("accessibilityRole") == nullptr) {
2383
+ out["accessibilityRole"] = "switch";
2384
+ }
2385
+
2386
+ const std::string *thumbColor = stringAt(props, "thumbColor");
2387
+ if (thumbColor != nullptr) out["thumbTintColor"] = *thumbColor;
2388
+
2389
+ // None of the three authored names is a native prop, and leaving one in the payload is how a
2390
+ // reader concludes the rule ran when it did not.
2391
+ out.erase("trackColor");
2392
+ out.erase("thumbColor");
2393
+ out.erase("ios_backgroundColor");
2394
+ return out;
2395
+ }
2396
+
2397
+ } // namespace
2398
+
2399
+ dynamic fabricProps(
2400
+ const std::string &component,
2401
+ const std::string &tagName,
2402
+ const dynamic &props,
2403
+ const IPayloadFold &fold,
2404
+ const IOwner &owner,
2405
+ const ISelf &self,
2406
+ const IAncestorLookup &ancestors,
2407
+ const IFirstChild &firstChild) {
2408
+ if (component == kRawTextComponent) {
2409
+ dynamic out = dynamic::object();
2410
+ const dynamic *text = props.get_ptr("text");
2411
+ if (text != nullptr) out["text"] = *text;
2412
+ // THE ONE TAG RULE ON THIS PATH, and a raw text is a stranger place for one than it looks. It
2413
+ // has no props an app can write — the object above is the whole payload — but its CONTENT can
2414
+ // still be the platform's decision rather than the app's, which is exactly what Button's title
2415
+ // is: rendered uppercase on Android and verbatim everywhere else (`Button.js:352-353`). That is
2416
+ // a user-agent choice about a control, so it belongs here and not in the app's string.
2417
+ //
2418
+ // Guarded on the tag rather than applied to every raw text, obviously — and the tag reaches a
2419
+ // raw text at all because `createRawText` now takes one, for this.
2420
+ if (tagName == "button-label") out = foldButtonLabel(out);
2421
+ return out;
2422
+ }
2423
+
2424
+ // THE ONE POINT WHERE THE WHOLE BAG IS KNOWN ON EVERY PATH, which is what the aria fold needs:
2425
+ // `aria-checked` has to be folded against a sibling `accessibilityState`, and a per-key write path
2426
+ // sees one key at a time. Both commit paths — create and clone — reach here, so a lowered element
2427
+ // gets the fold it has no wrapper to run.
2428
+ //
2429
+ // Threaded as a POINTER rather than a value, which is this side's version of the reference
2430
+ // returning its input by identity: a fold that has nothing to do must not copy the bag, and this
2431
+ // runs once per node per commit.
2432
+ const dynamic *bag = &props;
2433
+
2434
+ dynamic aliasFolded;
2435
+ if (hasAriaAlias(props)) {
2436
+ aliasFolded = foldAriaProps(props);
2437
+ bag = &aliasFolded;
2438
+ }
2439
+
2440
+ // The TAG's own platform props, in the slot the behavior's JS fold used to occupy — after the aria
2441
+ // fold and before anything else, because that order is load-bearing and always was: the aria fold
2442
+ // writes `accessibilityState` from `aria-disabled`, and this resolves that against `disabled`.
2443
+ // Swapped, whichever ran second would silently win.
2444
+ dynamic tagResolved;
2445
+ if (usesPressableRule(tagName)) {
2446
+ tagResolved = foldPressableProps(
2447
+ *bag,
2448
+ usesTouchableFeedbackRule(tagName),
2449
+ usesTouchableFocusableRule(tagName),
2450
+ self.hasPressListener,
2451
+ tagName == "pressable");
2452
+ // The UNDERLAY, layered over the touchable's own rule and only on the one tag that has one. It
2453
+ // runs AFTER `foldPressableProps` deliberately: that rule strips `underlayColor` and
2454
+ // `activeOpacity`, so this reads them off the AUTHORED bag — Trap A, the same correction every
2455
+ // rule that follows another has needed.
2456
+ if (tagName == "touchable-highlight")
2457
+ tagResolved = foldTouchableHighlightUnderlay(tagResolved, props, self);
2458
+ // Button is a touchable PLUS something, exactly as RN builds it (`Button.js:283`), so its own
2459
+ // rules layer over the touchable's rather than replacing them.
2460
+ if (tagName == "button")
2461
+ tagResolved = foldButtonProps(tagResolved, props, self.hasPressListener);
2462
+ bag = &tagResolved;
2463
+ } else if (tagName == "image" || tagName == "image-background-image") {
2464
+ // `aria-hidden` read off the RAW, pre-aria-fold `props`: by the time `*bag` reaches here the
2465
+ // aria fold has already erased the key in favour of `accessibilityElementsHidden`, and vendor's
2466
+ // own accessible formula (`Image.ios.js`/`Image.android.js`) keys specifically on `aria-hidden`
2467
+ // (the W3C name), not on whatever `accessibilityElementsHidden` an app may have set directly.
2468
+ tagResolved =
2469
+ foldImageProps(*bag, boolAt(props, "aria-hidden").value_or(false));
2470
+ // The background's inner image is an image PLUS a fill, and the order is the recorded
2471
+ // divergence from RN preserved exactly: the image rule folds this node's own `width`/`height`
2472
+ // props under its style FIRST, and the box proxied from the owner layers over that. RN nests it
2473
+ // the other way (`ImageBackground.js:83-98`); the decision is pinned in
2474
+ // `core/components/src/behaviors/image-background.test.ts` and moving the fold does not reopen it.
2475
+ if (tagName == "image-background-image")
2476
+ tagResolved = foldImageBackgroundImageProps(tagResolved, owner.props);
2477
+ bag = &tagResolved;
2478
+ } else if (tagName == "button-label-text") {
2479
+ tagResolved = foldButtonLabelStyle(*bag, ancestors);
2480
+ bag = &tagResolved;
2481
+ } else if (tagName == "scroll-view" || tagName == "horizontal-scroll-view") {
2482
+ // The parent's TAG decides which composition this is — the descendant seam again, read from the
2483
+ // other end. A scroll view whose parent is a refresh control is WRAPPED (Android's claim mode),
2484
+ // and it keeps only the visual half of its own style.
2485
+ tagResolved = foldScrollViewProps(
2486
+ *bag,
2487
+ tagName == "horizontal-scroll-view",
2488
+ owner.tagName != nullptr && std::strcmp(owner.tagName, "refresh-control") == 0);
2489
+ bag = &tagResolved;
2490
+ } else if (tagName == "refresh-control") {
2491
+ tagResolved = foldRefreshWrapperProps(*bag, firstChild);
2492
+ bag = &tagResolved;
2493
+ } else if (
2494
+ tagName == "scroll-content" || tagName == "horizontal-scroll-content") {
2495
+ tagResolved = foldScrollContentProps(
2496
+ *bag, tagName == "horizontal-scroll-content", owner.props);
2497
+ bag = &tagResolved;
2498
+ } else if (tagName == "image-background") {
2499
+ tagResolved = foldImageBackgroundProps(*bag);
2500
+ bag = &tagResolved;
2501
+ } else if (tagName == "activity-indicator") {
2502
+ tagResolved = foldActivityIndicatorProps(*bag);
2503
+ bag = &tagResolved;
2504
+ } else if (tagName == "sticky-header") {
2505
+ tagResolved = foldStickyHeaderProps(*bag);
2506
+ bag = &tagResolved;
2507
+ } else if (tagName == "activity-indicator-spinner") {
2508
+ tagResolved = foldActivityIndicatorSpinnerProps(
2509
+ *bag, component == kAndroidProgressBar);
2510
+ bag = &tagResolved;
2511
+ } else if (tagName == "switch") {
2512
+ // The COMPONENT decides the platform half, not a compile-time macro: `Switch` and
2513
+ // `AndroidSwitch` are two native components with two prop surfaces, and the name is already
2514
+ // here. It also makes both halves reachable from one test build.
2515
+ tagResolved = foldSwitchProps(*bag, component == kAndroidSwitchComponent);
2516
+ bag = &tagResolved;
2517
+ } else if (tagName == "input-accessory-view") {
2518
+ tagResolved = foldInputAccessoryViewProps(*bag);
2519
+ bag = &tagResolved;
2520
+ }
2521
+
2522
+ // THE DESCENDANT RULE, and it is its own step rather than an arm of the chain above because it is
2523
+ // keyed on a different thing. Every branch up there asks "what tag am I"; this asks "what tag
2524
+ // contains me", and a node can answer both — a `<pressable>` under a TouchableWithoutFeedback gets
2525
+ // its own rule AND the clone, in that order, which is the order RN composes them in.
2526
+ //
2527
+ // One `strcmp` against a usually-empty parent tag per node, placed after the chain so the common
2528
+ // case pays only that.
2529
+ dynamic ownerResolved;
2530
+ if (usesCloneOntoChildRule(owner.tagName)) {
2531
+ ownerResolved = foldCloneOntoChild(
2532
+ *bag, owner, std::strcmp(owner.tagName, "touchable-native-feedback") == 0);
2533
+ bag = &ownerResolved;
2534
+ } else if (
2535
+ owner.tagName != nullptr &&
2536
+ std::strcmp(owner.tagName, "touchable-highlight") == 0) {
2537
+ // The underlay's second half. `else if` rather than a second `if`: the two clone-onto-child tags
2538
+ // and this one are different owners, so no node can match both, and an independent branch would
2539
+ // buy a second `strcmp` on the hot path for a case that cannot happen.
2540
+ ownerResolved = foldTouchableHighlightChild(*bag, owner);
2541
+ bag = &ownerResolved;
2542
+ }
2543
+
2544
+ // The behavior's own fold, BETWEEN the two, which is where the reference runs it
2545
+ // (`fabric-props.ts`: aria -> payloadFold -> value). The order is not cosmetic: the aria fold
2546
+ // writes `accessibilityState` from `aria-disabled`, and Pressable's fold then resolves that
2547
+ // against its own `disabled`. Swapped, whichever ran second would silently win.
2548
+ // The fold's return REPLACES the bag, which is what makes a removal expressible at all — a fold
2549
+ // drops a key by not putting it back. That is also what makes it expensive: every fold returns
2550
+ // `{ ...props, ...whatItChanged }`, and reading the result back here is `jsi::dynamicFromValue`,
2551
+ // a `getPropertyNames` plus a `getString` and a `std::string` allocation per key. Measured at
2552
+ // 13.3 ms of a 17.8 ms fold phase, against 1.6 ms to send the bag out and 1.6 ms to run the fold.
2553
+ // `core/engine/src/__tests__/payload-fold-merge.test.ts` holds the numbers and why merging a patch
2554
+ // instead — the obvious cut — does not fit yet.
2555
+ dynamic behaviorFolded;
2556
+ if (fold) {
2557
+ behaviorFolded = fold(*bag);
2558
+ bag = &behaviorFolded;
2559
+ }
2560
+
2561
+ dynamic valueFolded;
2562
+ const bool isTextInput =
2563
+ component == kSinglelineTextInput || component == kMultilineTextInput;
2564
+ if (isTextInput &&
2565
+ (bag->get_ptr("value") != nullptr || bag->get_ptr("defaultValue") != nullptr)) {
2566
+ valueFolded = foldTextInputValue(*bag);
2567
+ bag = &valueFolded;
2568
+ }
2569
+
2570
+ // TextInput's web spelling resolved into RN's own — the UA rule, moved off a JS `payloadFold`.
2571
+ // See `foldTextInputAliases`. Unconditional because `submitBehavior` is produced for an empty bag,
2572
+ // which is what makes this a rule rather than a mapping.
2573
+ dynamic aliasResolved;
2574
+ if (isTextInput) {
2575
+ aliasResolved = foldTextInputAliases(*bag, component == kMultilineTextInput);
2576
+ bag = &aliasResolved;
2577
+ }
2578
+
2579
+ // RN's two Text defaults (`Text.js:289` and `:291`), applied here so no adapter has to write them
2580
+ // as props. Three of them used to (`seedTextDefaults` in Vue, Angular and Solid): both keys landed
2581
+ // on every text node at `createElement`, the app then authored the same values, and each write
2582
+ // crossed into this host, converted to a `folly::dynamic` and was dropped for equalling what was
2583
+ // already there — 6 000 wasted crossings per 1 000-row create, measured with `writesOfUnchanged`.
2584
+ //
2585
+ // LAST of the component-keyed folds, so a behavior's own fold still gets to set either key and win.
2586
+ // A fallback and never an override, and `!= false` rather than "is missing": RN treats an explicit
2587
+ // `undefined` and an absent prop alike, and only a literal `false` opts out. The twin of this rule
2588
+ // is `applyTextDefaults` in `core/engine/src/fabric-props.ts`, and
2589
+ // `core/engine/src/__tests__/text-payload-defaults.test.ts` is what keeps the two copies honest.
2590
+ dynamic textDefaulted;
2591
+ if (component == kTextComponent) {
2592
+ textDefaulted = *bag;
2593
+ const dynamic *ellipsize = textDefaulted.get_ptr("ellipsizeMode");
2594
+ if (ellipsize == nullptr || ellipsize->isNull()) {
2595
+ textDefaulted["ellipsizeMode"] = "tail";
2596
+ }
2597
+ const dynamic *scaling = textDefaulted.get_ptr("allowFontScaling");
2598
+ const bool optedOut =
2599
+ scaling != nullptr && scaling->isBool() && scaling->getBool() == false;
2600
+ textDefaulted["allowFontScaling"] = !optedOut;
2601
+ bag = &textDefaulted;
2602
+ }
2603
+
2604
+ const dynamic &folded = *bag;
2605
+ dynamic out = dynamic::object();
2606
+ for (const auto &pair : folded.items()) {
2607
+ if (!pair.first.isString()) continue;
2608
+ const std::string &key = pair.first.getString();
2609
+ if (key == "style") continue;
2610
+ out[key] = processValue(key, pair.second);
2611
+ }
2612
+ // Hoist the style slot (object | array | nested arrays) into the SAME payload object — no
2613
+ // intermediate flatten. See `addStyle`.
2614
+ const dynamic *style = folded.get_ptr("style");
2615
+ if (style != nullptr) addStyle(out, *style);
2616
+ return out;
2617
+ }
2618
+
2619
+ } // namespace symbiote