Skip to content

Commit c26766e

Browse files
committed
fix: cache selector group UIAppearance accessors
1 parent 9699fef commit c26766e

7 files changed

Lines changed: 162 additions & 0 deletions

File tree

HANDOFF.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,36 @@ During this thread `agent-device` was version `0.18.0`; the npm package is
111111

112112
## Current Verified State
113113

114+
Update from 2026-06-29 10:22 EDT:
115+
116+
- Simulator-only rule remains active. Do not use physical iPhone/iPad devices.
117+
- GitHub Actions run `28376322394` on `9699fefc` kept setup, dependency
118+
install, FFI boundary, V8 download, libffi build, metadata generation,
119+
NativeScript build, CLI build, and macOS tests green, but still failed iOS
120+
only in `ApiTests.js Appearance`: `UILabel.appearance().textColor` read
121+
back `undefined`.
122+
- CI disproved the protocol-accessor cache as the final missing path. The
123+
likely bypass is the generated selector-group functions installed as JS
124+
property accessors by `Install.mm`, which call metadata selectors directly
125+
instead of `NativeApiObjectHostObject::get/set` or protocol accessors.
126+
- Runtime fix in progress:
127+
- Prepared Objective-C invocations now record the JS property name for
128+
one-argument metadata property setters such as `setTextColor:`.
129+
- Selector-group property getters now consult the target-class-scoped
130+
UIAppearance expando cache before calling UIKit's proxy getter.
131+
- JSC, QuickJS, Hermes, and V8 selector-group generated/fast/generic setter
132+
paths now populate the same cache after a successful native setter call.
133+
- Local verification after this patch:
134+
- Runtime RN JS tests passed:
135+
`for f in packages/react-native/test/*.test.js; do node "$f" || exit 1; done`.
136+
- `npm run check:ffi-boundaries` passed.
137+
- `git diff --check` passed.
138+
- `npm run build:macos-cli` passed.
139+
- Not done:
140+
- Commit/push the selector-group UIAppearance cache fix and watch fresh PR
141+
CI.
142+
- If CI turns green, resume the dedicated simulator-only RNS parity sweep.
143+
114144
Update from 2026-06-29 09:38 EDT:
115145

116146
- Simulator-only rule remains active. Do not use physical iPhone/iPad devices.

NativeScript/ffi/hermes/NativeApiJsi.mm

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,13 @@ throw JSError(runtime,
175175

176176
const bool propertyGetterCall =
177177
entry.hasMember && entry.member.property && count == 0;
178+
if (propertyGetterCall) {
179+
Value appearanceExpando = cachedAppearanceProxyPropertyValue(
180+
runtime, bridge, receiver, entry.member.name);
181+
if (!appearanceExpando.isUndefined()) {
182+
return appearanceExpando;
183+
}
184+
}
178185
const std::string* selectorNamePtr = &entry.selectorName;
179186
const NativeApiMember* selectedMember =
180187
entry.hasMember ? &entry.member : nullptr;
@@ -267,6 +274,8 @@ throw JSError(runtime,
267274
GsdObjCContext ctx{runtime, bridge, receiver, prepared->selector,
268275
args, prepared->signature.returnType};
269276
if (invoker(ctx)) {
277+
cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver,
278+
*prepared, args, count);
270279
return std::move(ctx.result);
271280
}
272281
}

NativeScript/ffi/jsc/NativeApiJSCSelectorGroups.mm

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ throw JSError(
112112
GsdObjCContext ctx{runtime, bridge, receiver, prepared.selector,
113113
runtime.context(), arguments, signature.returnType};
114114
if (invoker(ctx)) {
115+
if (providedCount > 0) {
116+
Value setterValue = Value::borrowed(runtime, arguments[0]);
117+
cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver,
118+
prepared, &setterValue, 1);
119+
}
115120
return ctx.result;
116121
}
117122
}
@@ -126,6 +131,9 @@ throw JSError(
126131
if (tryCallFastEngineObjCSelector(runtime, bridge, receiver, prepared,
127132
fastArgs, providedCount, Nil,
128133
&fastResult)) {
134+
cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver,
135+
prepared, fastArgs,
136+
providedCount);
129137
fastResult = tagPreparedStaticAppearanceSelectorResult(
130138
runtime, bridge, receiver, prepared, std::move(fastResult));
131139
return fastResult.local(runtime);
@@ -198,6 +206,11 @@ NativeApiReturnStorage returnStorage(
198206
throw JSError(
199207
runtime, errorMessage != nullptr ? errorMessage : "Unknown NSError");
200208
}
209+
if (providedCount > 0) {
210+
Value setterValue = Value::borrowed(runtime, arguments[0]);
211+
cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver,
212+
prepared, &setterValue, 1);
213+
}
201214
if (initializerClassWrapper) {
202215
id resultObject = nil;
203216
if (isObjectiveCObjectType(returnType)) {
@@ -270,6 +283,13 @@ throw JSError(runtime,
270283

271284
const bool propertyGetterCall =
272285
entry.hasMember && entry.member.property && argumentCount == 0;
286+
if (propertyGetterCall) {
287+
Value appearanceExpando = cachedAppearanceProxyPropertyValue(
288+
runtime, data->bridge, receiver, entry.member.name);
289+
if (!appearanceExpando.isUndefined()) {
290+
return appearanceExpando.local(runtime);
291+
}
292+
}
273293
const std::string* selectorNamePtr = &entry.selectorName;
274294
const NativeApiMember* selectedMember =
275295
entry.hasMember ? &entry.member : nullptr;

NativeScript/ffi/quickjs/NativeApiQuickJSSelectorGroups.mm

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ throw JSError(
111111
GsdObjCContext ctx{runtime, bridge, receiver, prepared.selector,
112112
runtime.context(), arguments, signature.returnType};
113113
if (invoker(ctx)) {
114+
if (providedCount > 0) {
115+
Value setterValue = Value::borrowed(runtime, arguments[0]);
116+
cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver,
117+
prepared, &setterValue, 1);
118+
}
114119
return ctx.result;
115120
}
116121
}
@@ -125,6 +130,9 @@ throw JSError(
125130
if (tryCallFastEngineObjCSelector(runtime, bridge, receiver, prepared,
126131
fastArgs, providedCount, Nil,
127132
&fastResult)) {
133+
cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver,
134+
prepared, fastArgs,
135+
providedCount);
128136
fastResult = tagPreparedStaticAppearanceSelectorResult(
129137
runtime, bridge, receiver, prepared, std::move(fastResult));
130138
return fastResult.local(runtime);
@@ -198,6 +206,11 @@ NativeApiReturnStorage returnStorage(
198206
throw JSError(
199207
runtime, errorMessage != nullptr ? errorMessage : "Unknown NSError");
200208
}
209+
if (providedCount > 0) {
210+
Value setterValue = Value::borrowed(runtime, arguments[0]);
211+
cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver,
212+
prepared, &setterValue, 1);
213+
}
201214
if (initializerClassWrapper) {
202215
id resultObject = nil;
203216
if (isObjectiveCObjectType(returnType)) {
@@ -292,6 +305,13 @@ throw JSError(runtime,
292305

293306
const bool propertyGetterCall =
294307
entry.hasMember && entry.member.property && count == 0;
308+
if (propertyGetterCall) {
309+
Value appearanceExpando = cachedAppearanceProxyPropertyValue(
310+
runtime, data->bridge, receiver, entry.member.name);
311+
if (!appearanceExpando.isUndefined()) {
312+
return appearanceExpando.local(runtime);
313+
}
314+
}
295315
const std::string* selectorNamePtr = &entry.selectorName;
296316
const NativeApiMember* selectedMember =
297317
entry.hasMember ? &entry.member : nullptr;

NativeScript/ffi/shared/bridge/Invocation.mm

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,7 @@ bool signatureSupportedForEngineInvocation(
521521
SEL selector = nullptr;
522522
Class receiverClass = Nil;
523523
std::string selectorName;
524+
std::string propertySetterName;
524525
NativeApiSignature signature;
525526
ObjCPreparedInvoker preparedInvoker = nullptr;
526527
void* engineInvoker = nullptr; // Engine-neutral GSD invoker (ObjCGsdInvoker)
@@ -539,6 +540,17 @@ bool preparedObjCInvocationIsInit(
539540
return prepared.isInitMethod;
540541
}
541542

543+
void cachePreparedAppearanceProxySetterValue(
544+
Runtime& runtime, const std::shared_ptr<NativeApiBridge>& bridge,
545+
id receiver, const NativeApiPreparedObjCInvocation& prepared,
546+
const Value* args, size_t count) {
547+
if (prepared.propertySetterName.empty() || args == nullptr || count == 0) {
548+
return;
549+
}
550+
cacheAppearanceProxyPropertyValue(runtime, bridge, receiver,
551+
prepared.propertySetterName, args[0]);
552+
}
553+
542554
bool isFastEngineObjectType(const NativeApiType& type) {
543555
switch (type.kind) {
544556
case metagen::mdTypeAnyObject:
@@ -1472,6 +1484,12 @@ throw JSError(
14721484
prepared->selector = selector;
14731485
prepared->receiverClass = receiverIsClass ? lookupClass : Nil;
14741486
prepared->selectorName = selectorName;
1487+
if (member != nullptr && member->property && !member->name.empty() &&
1488+
!member->setterSelectorName.empty() &&
1489+
selectorName == member->setterSelectorName &&
1490+
selectorArgumentCount(selectorName) == 1) {
1491+
prepared->propertySetterName = member->name;
1492+
}
14751493
prepared->signature = std::move(*signature);
14761494
prepared->preparedInvoker = lookupObjCPreparedInvoker(
14771495
dispatchIdForEngineSignature(prepared->signature,
@@ -1531,11 +1549,15 @@ throw JSError(runtime,
15311549
if (tryCallGeneratedEngineObjCSelector(runtime, bridge, receiver, prepared,
15321550
args, count, dispatchSuperClass,
15331551
&fastResult)) {
1552+
cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver,
1553+
prepared, args, count);
15341554
return tagPreparedStaticAppearanceSelectorResult(
15351555
runtime, bridge, receiver, prepared, std::move(fastResult));
15361556
}
15371557
if (tryCallFastEngineObjCSelector(runtime, bridge, receiver, prepared, args,
15381558
count, dispatchSuperClass, &fastResult)) {
1559+
cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver,
1560+
prepared, args, count);
15391561
return tagPreparedStaticAppearanceSelectorResult(
15401562
runtime, bridge, receiver, prepared, std::move(fastResult));
15411563
}
@@ -1624,6 +1646,8 @@ NativeApiReturnStorage returnStorage(
16241646
throw JSError(
16251647
runtime, errorMessage != nullptr ? errorMessage : "Unknown NSError");
16261648
}
1649+
cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver, prepared,
1650+
args, count);
16271651
Value result = convertNativeReturnValue(runtime, bridge, returnType,
16281652
returnStorage.data());
16291653
return tagPreparedStaticAppearanceSelectorResult(

NativeScript/ffi/v8/NativeApiV8SelectorGroups.mm

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ throw JSError(
106106
runtime.context(),
107107
signature.returnType};
108108
if (invoker(ctx)) {
109+
if (providedCount > 0) {
110+
Value setterValue = Value::borrowed(runtime, info[0]);
111+
cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver,
112+
prepared, &setterValue, 1);
113+
}
109114
return;
110115
}
111116
}
@@ -120,6 +125,9 @@ throw JSError(
120125
if (tryCallFastEngineObjCSelector(runtime, bridge, receiver, prepared,
121126
fastArgs, providedCount, Nil,
122127
&fastResult)) {
128+
cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver,
129+
prepared, fastArgs,
130+
providedCount);
123131
fastResult = tagPreparedStaticAppearanceSelectorResult(
124132
runtime, bridge, receiver, prepared, std::move(fastResult));
125133
info.GetReturnValue().Set(fastResult.local(runtime));
@@ -193,6 +201,11 @@ NativeApiReturnStorage returnStorage(
193201
throw JSError(
194202
runtime, errorMessage != nullptr ? errorMessage : "Unknown NSError");
195203
}
204+
if (providedCount > 0) {
205+
Value setterValue = Value::borrowed(runtime, info[0]);
206+
cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver,
207+
prepared, &setterValue, 1);
208+
}
196209
if (initializerClassWrapper) {
197210
id resultObject = nil;
198211
if (isObjectiveCObjectType(returnType)) {
@@ -263,6 +276,14 @@ throw JSError(runtime,
263276

264277
const bool propertyGetterCall =
265278
entry.hasMember && entry.member.property && count == 0;
279+
if (propertyGetterCall) {
280+
Value appearanceExpando = cachedAppearanceProxyPropertyValue(
281+
runtime, data->bridge, receiver, entry.member.name);
282+
if (!appearanceExpando.isUndefined()) {
283+
info.GetReturnValue().Set(appearanceExpando.local(runtime));
284+
return;
285+
}
286+
}
266287
const std::string* selectorNamePtr = &entry.selectorName;
267288
const NativeApiMember* selectedMember =
268289
entry.hasMember ? &entry.member : nullptr;
@@ -389,6 +410,12 @@ throw JSError(runtime,
389410
runtime.context(),
390411
prepared->signature.returnType};
391412
if (invoker(ctx)) {
413+
if (count > 0) {
414+
Value setterValue = Value::borrowed(runtime, info[0]);
415+
cachePreparedAppearanceProxySetterValue(runtime, data->bridge,
416+
receiver, *prepared,
417+
&setterValue, 1);
418+
}
392419
return;
393420
}
394421
}

PROGRESS.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,38 @@ TypeScript/UI worklets.
1717

1818
## Latest Update - 2026-06-29
1919

20+
### 2026-06-29 10:22 EDT - cached selector-group UIAppearance accessors
21+
22+
- Goal:
23+
- Keep PR #46 on the generic runtime primitive path and unblock the final
24+
simulator-only RN Screens parity sweep; no physical devices were used.
25+
- CI finding:
26+
- GitHub Actions run `28376322394` on `9699fefc` kept setup, dependencies,
27+
FFI boundary check, V8 download, libffi build, metadata generation,
28+
NativeScript build, CLI build, and macOS tests green, but still failed iOS
29+
`ApiTests.js Appearance` only: `UILabel.appearance().textColor` read back
30+
as `undefined`.
31+
- That disproved the protocol-accessor cache as the remaining path. The
32+
failing assignment/readback is consistent with metadata property accessors
33+
installed by `Install.mm`, where JS getter/setter functions call selector
34+
groups directly and bypass object-host/protocol accessors.
35+
- Changes:
36+
- Prepared Objective-C invocations now record the JS property name for
37+
one-argument metadata property setters.
38+
- Selector-group property getters now consult the target-class-scoped
39+
UIAppearance expando cache before calling through to UIKit's proxy getter.
40+
- JSC, QuickJS, Hermes, and V8 selector-group generated/fast/generic setter
41+
paths now populate that cache after successful native setter calls.
42+
- Verification:
43+
- Runtime RN JS tests passed:
44+
`for f in packages/react-native/test/*.test.js; do node "$f" || exit 1; done`.
45+
- `npm run check:ffi-boundaries` passed.
46+
- `git diff --check` passed.
47+
- `npm run build:macos-cli` passed.
48+
- Still next:
49+
- Commit/push the selector-group cache fix, watch fresh PR CI, then return
50+
to the dedicated simulator-only RNS parity sweep if CI is green.
51+
2052
### 2026-06-29 09:38 EDT - cached protocol-backed UIAppearance accessors
2153

2254
- Goal:

0 commit comments

Comments
 (0)