@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.
- package/README.md +39 -14
- package/android/CMakeLists.txt +51 -0
- package/android/build.gradle +90 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/cpp/SymbioteEngineJni.cpp +72 -0
- package/android/src/main/java/dev/symbiotenative/engine/SymbioteEngineModule.kt +43 -0
- package/android/src/main/java/dev/symbiotenative/engine/SymbioteEnginePackage.kt +35 -0
- package/build/accessibility-info/shared.js +1 -1
- package/build/accessibility-props.d.ts +1 -8
- package/build/accessibility-props.js +13 -16
- package/build/animated/animations/composition.d.ts +1 -1
- package/build/animated/animations/composition.js +18 -4
- package/build/animated/easing.d.ts +3 -2
- package/build/animated/easing.js +17 -88
- package/build/animated/event.js +6 -1
- package/build/animated/host-binding.d.ts +1 -1
- package/build/animated/host-binding.js +19 -4
- package/build/animated/index.d.ts +1 -1
- package/build/animated/mock.d.ts +1 -19
- package/build/animated/props.js +1 -1
- package/build/animated/rgba.js +16 -50
- package/build/events/index.js +88 -40
- package/build/fabric-props.d.ts +1 -1
- package/build/fabric-props.js +116 -184
- package/build/fabric.d.ts +9 -0
- package/build/fabric.js +32 -0
- package/build/host-access.d.ts +125 -0
- package/build/host-access.js +280 -0
- package/build/host-behavior.d.ts +84 -21
- package/build/host-behavior.js +196 -30
- package/build/image-source-write.d.ts +16 -0
- package/build/image-source-write.js +65 -0
- package/build/imperative.d.ts +49 -0
- package/build/imperative.js +258 -0
- package/build/index.d.ts +14 -7
- package/build/index.js +53 -10
- package/build/mutation-buffer.d.ts +222 -0
- package/build/mutation-buffer.js +491 -0
- package/build/native-engine.d.ts +182 -0
- package/build/native-engine.js +178 -0
- package/build/native-tree-host.d.ts +25 -0
- package/build/native-tree-host.js +66 -0
- package/build/node.d.ts +172 -57
- package/build/node.js +839 -383
- package/build/pan-responder/index.js +27 -52
- package/build/platform-color/index.d.ts +1 -1
- package/build/platform-color/index.js +11 -4
- package/build/process-background-image/index.js +30 -566
- package/build/process-background-longhands.d.ts +4 -0
- package/build/process-background-longhands.js +44 -0
- package/build/process-box-shadow/index.js +23 -187
- package/build/process-filter.js +27 -300
- package/build/process-transform/index.d.ts +1 -1
- package/build/process-transform/index.js +25 -107
- package/build/process-transform-origin/index.d.ts +1 -1
- package/build/process-transform-origin/index.js +29 -102
- package/build/registry.d.ts +36 -0
- package/build/registry.js +73 -0
- package/build/sound-manager/index.d.ts +3 -0
- package/build/sound-manager/index.js +36 -0
- package/build/structured-style.d.ts +10 -0
- package/build/structured-style.js +180 -0
- package/build/style-registry/index.d.ts +14 -0
- package/build/style-registry/index.js +60 -11
- package/build/surface.d.ts +31 -2
- package/build/surface.js +138 -56
- package/build/text-input-state.d.ts +1 -0
- package/build/text-input-state.js +17 -3
- package/build/tree-host.d.ts +307 -0
- package/build/tree-host.js +211 -0
- package/build/view-config.js +4 -4
- package/codegen-specs/NativeSymbioteEngine.ts +27 -0
- package/cpp/SymbioteDebug.cpp +51 -0
- package/cpp/SymbioteDebug.h +54 -0
- package/cpp/SymbioteEngineBindings.cpp +232 -0
- package/cpp/SymbioteEngineBindings.h +59 -0
- package/cpp/SymbioteFabricProps.cpp +2619 -0
- package/cpp/SymbioteFabricProps.h +223 -0
- package/cpp/SymbioteTree.cpp +2478 -0
- package/cpp/SymbioteTree.h +257 -0
- package/ios/SymbioteEngineModule.h +25 -0
- package/ios/SymbioteEngineModule.mm +44 -0
- package/package.json +31 -3
- package/react-native.config.cjs +23 -0
- package/symbiote-engine.podspec +42 -0
- package/build/animated/bezier.d.ts +0 -1
- package/build/animated/bezier.js +0 -102
- package/build/commit.d.ts +0 -49
- package/build/commit.js +0 -1058
- package/build/tags.d.ts +0 -2
- 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
|