Skip to content

Commit b016ae8

Browse files
committed
fix: preserve initializer receiver identity
1 parent 20dbc6b commit b016ae8

9 files changed

Lines changed: 155 additions & 38 deletions

File tree

NativeScript/ffi/hermes/NativeApiJsi.mm

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,8 +303,15 @@ throw JSError(runtime,
303303
throw JSError(runtime,
304304
"Objective-C selector requires a native receiver.");
305305
}
306-
return receiverHostObject->callPreparedObjectSelector(
306+
Value result = receiverHostObject->callPreparedObjectSelector(
307307
runtime, *prepared, args, count, gsdDispatchClass);
308+
if (!receiverIsClass && prepared->isInitMethod) {
309+
if (auto preserved = preservedNativeApiInitializerSelfReturn(
310+
runtime, bridge, receiver, result, thisValue)) {
311+
return *preserved;
312+
}
313+
}
314+
return result;
308315
});
309316
}
310317

NativeScript/ffi/jsc/NativeApiJSCSelectorGroups.mm

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,9 +398,18 @@ throw JSError(runtime,
398398
data->cachedDispatchClass = dispatchClass;
399399
}
400400
}
401-
return setJSCEnginePreparedObjCResult(
401+
JSValueRef result = setJSCEnginePreparedObjCResult(
402402
runtime, data->bridge, receiver, *prepared, receiverHostObject,
403403
initializerClassWrapper, argumentCount, arguments, dispatchClass);
404+
if (!data->receiverIsClass && prepared->isInitMethod &&
405+
thisObject != nullptr) {
406+
if (auto preserved = preservedNativeApiInitializerSelfReturn(
407+
runtime, data->bridge, receiver, Value::borrowed(runtime, result),
408+
Value::borrowed(runtime, thisObject))) {
409+
return preserved->local(runtime);
410+
}
411+
}
412+
return result;
404413
} catch (const std::exception& error) {
405414
engine::jscengine::setException(context, exception, error);
406415
return JSValueMakeUndefined(context);

NativeScript/ffi/quickjs/NativeApiQuickJSSelectorGroups.mm

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,9 +411,18 @@ throw JSError(runtime,
411411
data->cachedDispatchClass = dispatchClass;
412412
}
413413
}
414-
return setQuickJSEnginePreparedObjCResult(
414+
JSValue result = setQuickJSEnginePreparedObjCResult(
415415
runtime, data->bridge, receiver, *prepared, receiverHostObject,
416416
initializerClassWrapper, count, argv, dispatchClass);
417+
if (!data->receiverIsClass && prepared->isInitMethod) {
418+
if (auto preserved = preservedNativeApiInitializerSelfReturn(
419+
runtime, data->bridge, receiver, Value::borrowed(runtime, result),
420+
Value::borrowed(runtime, thisValue))) {
421+
JS_FreeValue(context, result);
422+
return preserved->local(runtime);
423+
}
424+
}
425+
return result;
417426
} catch (const std::exception& error) {
418427
return engine::quickjsengine::throwError(context, error);
419428
}

NativeScript/ffi/shared/bridge/ClassBuilder.mm

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,36 @@ void rememberNativeApiKnownExposedMethod(
8080
return std::nullopt;
8181
}
8282

83+
std::optional<Value> preservedNativeApiInitializerSelfReturn(
84+
Runtime& runtime, const std::shared_ptr<NativeApiBridge>& bridge,
85+
id receiver, const Value& result, const Value& receiverValue) {
86+
if (bridge == nullptr || receiver == nil || !receiverValue.isObject()) {
87+
return std::nullopt;
88+
}
89+
90+
id resultObject =
91+
NativeApiObjectHostObject::nativeObjectFromValue(runtime, result);
92+
if (resultObject != receiver) {
93+
return std::nullopt;
94+
}
95+
96+
Object receiverObject = receiverValue.asObject(runtime);
97+
if (!receiverObject.isHostObject<NativeApiObjectHostObject>(runtime)) {
98+
return std::nullopt;
99+
}
100+
101+
auto receiverHostObject =
102+
receiverObject.getHostObject<NativeApiObjectHostObject>(runtime);
103+
if (receiverHostObject == nullptr ||
104+
receiverHostObject->object() != receiver) {
105+
return std::nullopt;
106+
}
107+
108+
Value preserved(runtime, receiverValue);
109+
bridge->rememberRoundTripValue(runtime, receiver, preserved);
110+
return preserved;
111+
}
112+
83113
Value callNativeApiBaseObjectSelector(
84114
Runtime& runtime, const std::shared_ptr<NativeApiBridge>& bridge,
85115
const Object& receiverObject,
@@ -90,19 +120,15 @@ Value callNativeApiBaseObjectSelector(
90120
Value result = receiverHostObject->callObjectSelector(
91121
runtime, selectorName, member, args, count, dispatchClass);
92122

93-
if (selectorName.rfind("init", 0) != 0 || receiver == nil) {
123+
if (selectorName.rfind("init", 0) != 0) {
94124
return result;
95125
}
96126

97-
id resultObject =
98-
NativeApiObjectHostObject::nativeObjectFromValue(runtime, result);
99-
if (resultObject != receiver) {
100-
return result;
127+
if (auto preserved = preservedNativeApiInitializerSelfReturn(
128+
runtime, bridge, receiver, result, Value(runtime, receiverObject))) {
129+
return *preserved;
101130
}
102-
103-
bridge->rememberRoundTripValue(runtime, receiver,
104-
Value(runtime, receiverObject));
105-
return Value(runtime, receiverObject);
131+
return result;
106132
}
107133

108134
const char* nativeApiEngineFastEnumerationEncoding() {

NativeScript/ffi/shared/bridge/Install.mm

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -972,18 +972,7 @@ function installSelectorGroups(target, groups, receiverIsClass) {
972972
return rememberInstanceClass(fn(...args));
973973
};
974974
})(selectorFunction, name)
975-
: (function(fn, memberName) {
976-
return function() {
977-
if (this && typeof this === 'object' && this.kind === 'object') {
978-
var baseArgs = [nativeClass, this, memberName];
979-
for (var baseArgIndex = 0; baseArgIndex < arguments.length; baseArgIndex++) {
980-
baseArgs.push(arguments[baseArgIndex]);
981-
}
982-
return api.__invokeBase(...baseArgs);
983-
}
984-
return fn.apply(this, arguments);
985-
};
986-
})(selectorFunction, name)
975+
: selectorFunction
987976
});
988977
}
989978
}

NativeScript/ffi/v8/NativeApiV8SelectorGroups.mm

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,14 @@ throw JSError(runtime,
422422
setV8EnginePreparedObjCResult(runtime, data->bridge, receiver, *prepared,
423423
receiverHostObject, initializerClassWrapper,
424424
info, dispatchClass);
425+
if (!data->receiverIsClass && prepared->isInitMethod) {
426+
if (auto preserved = preservedNativeApiInitializerSelfReturn(
427+
runtime, data->bridge, receiver,
428+
Value(runtime, info.GetReturnValue().Get()),
429+
Value(runtime, info.This()))) {
430+
info.GetReturnValue().Set(preserved->local(runtime));
431+
}
432+
}
425433
} catch (const std::exception& exception) {
426434
engine::v8engine::throwV8Exception(info.GetIsolate(), exception);
427435
}

PROGRESS.md

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

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

20+
### 2026-06-29 21:15 EDT - initializer self-return receiver preservation
21+
22+
- Goal:
23+
- Continue unblocking PR #46's runtime CI without drifting back into the
24+
original Node-API/direct-engine refactor branch. Simulator-only rule remains
25+
active; no physical devices were used.
26+
- CI finding:
27+
- Run `28412396190` still failed in macOS `ApiTests.js
28+
NSMutableArrayMethods` with `_array` missing during native `addObject:`.
29+
- The previous broad JS instance-selector trampoline had moved execution past
30+
the iOS init hang, but it also bypassed the JS wrapper that should carry
31+
`_array`, so it was the wrong layer for the final fix.
32+
- Changes:
33+
- Removed the global instance selector wrapper that routed native receivers
34+
through `__invokeBase`.
35+
- Added a generic bridge helper that preserves the original JS receiver when
36+
an Objective-C initializer returns the same native object.
37+
- Wired that helper into JSC, V8, QuickJS, Hermes, and the existing
38+
`__invokeBase` path so `Base.prototype.init.apply(this, arguments)` keeps
39+
receiver identity without hijacking ordinary instance selector dispatch.
40+
- Verification:
41+
- Runtime RN JS tests passed:
42+
`for f in packages/react-native/test/*.test.js; do node "$f" || exit 1; done`.
43+
- `git diff --check` passed.
44+
- `npm run check:ffi-boundaries` passed.
45+
- `npm run build:macos-cli` passed.
46+
- Focused local macOS `NSMutableArrayMethods` testing is still blocked before
47+
launch by the known metadata-generator x86_64/libclang mismatch, so GitHub
48+
Actions remains the native macOS/iOS simulator authority.
49+
- Still next:
50+
- Commit/push the initializer receiver preservation fix and watch fresh PR CI.
51+
- If CI proves the runtime fix, clean the diagnostic history before resuming
52+
the dedicated simulator-only RNS parity sweep.
53+
2054
### 2026-06-29 18:19 EDT - indexed collection subclass aliases
2155

2256
- Goal:

RN_API.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,20 @@ UIKit-backed React Native libraries in TypeScript/UI worklets.
4141
- Objective-C `Class` values returned through generic object conversion must
4242
become native class wrappers, not ordinary object wrappers.
4343

44+
## 2026-06-29 Native Initializer Receiver Identity
45+
46+
- No new public NativeScript React Native API was added.
47+
- Generic native bridge behavior was clarified for JS/TypeScript native
48+
subclasses:
49+
- When an Objective-C initializer called through a base prototype returns the
50+
same native receiver, the bridge must return and re-cache the original JS
51+
receiver object.
52+
- This preserves JS expandos/state established in upstream-style
53+
`Base.prototype.init.apply(this, arguments)` overrides without routing all
54+
instance selector calls through a JS `__invokeBase` trampoline.
55+
- If native initialization substitutes a different Objective-C object, the
56+
bridge must keep returning the substituted object wrapper.
57+
4458
## 2026-06-29 Simulator Latency Comparison
4559

4660
- No new public NativeScript React Native API was added.

packages/react-native/test/runtime-instance-selector-base-dispatch.test.js

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,19 @@ for (const relativePath of [
1212

1313
const invokeBaseCount = source.match(/return api\.__invokeBase\(\.\.\.baseArgs\);/g)?.length ?? 0;
1414
assert(
15-
invokeBaseCount >= 2,
16-
`${relativePath}: class and instance selector wrappers should route native receivers through __invokeBase`,
15+
invokeBaseCount === 1,
16+
`${relativePath}: only class selector wrappers should route native receivers through __invokeBase`,
1717
);
1818
assert(
19-
source.includes("return fn.apply(this, arguments);"),
20-
`${relativePath}: instance selector wrapper should preserve normal this-bound fallback dispatch`,
19+
!source.includes("return fn.apply(this, arguments);"),
20+
`${relativePath}: instance selector wrappers should not globally reroute native receivers through __invokeBase`,
2121
);
2222
assert(
2323
source.includes("value: receiverIsClass") &&
24-
source.includes(": (function(fn, memberName) {") &&
25-
source.includes("var baseArgs = [nativeClass, this, memberName];"),
26-
`${relativePath}: instance selector wrapper should build base invocation arguments from nativeClass, receiver, and member name`,
24+
source.includes("? (function(fn, memberName) {") &&
25+
source.includes("var baseArgs = [nativeClass, this, memberName];") &&
26+
source.includes(": selectorFunction"),
27+
`${relativePath}: class selector wrappers should keep base invocation support while instance selectors use engine dispatch`,
2728
);
2829
}
2930

@@ -34,19 +35,39 @@ for (const relativePath of [
3435
const source = fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
3536

3637
assert(
37-
source.includes("Value callNativeApiBaseObjectSelector("),
38-
`${relativePath}: base dispatch should go through the receiver-preserving helper`,
38+
source.includes("std::optional<Value> preservedNativeApiInitializerSelfReturn("),
39+
`${relativePath}: runtime should expose the initializer self-preservation helper`,
3940
);
4041
assert(
41-
source.includes('selectorName.rfind("init", 0) != 0') &&
42+
source.includes("receiverHostObject->object() != receiver") &&
4243
source.includes("NativeApiObjectHostObject::nativeObjectFromValue(runtime, result)") &&
4344
source.includes("bridge->rememberRoundTripValue(runtime, receiver,") &&
44-
source.includes("return Value(runtime, receiverObject);"),
45-
`${relativePath}: base initializer dispatch should preserve receiver wrapper identity when init returns self`,
45+
source.includes("return preserved;"),
46+
`${relativePath}: initializer dispatch should preserve the original JS receiver when native init returns self`,
4647
);
4748
assert(
48-
source.includes("return callNativeApiBaseObjectSelector("),
49-
`${relativePath}: __invokeBase should use receiver-preserving selector calls`,
49+
source.includes("preservedNativeApiInitializerSelfReturn(") &&
50+
source.includes("Value(runtime, receiverObject)"),
51+
`${relativePath}: __invokeBase should use the shared initializer self-preservation helper`,
52+
);
53+
}
54+
55+
for (const relativePath of [
56+
"NativeScript/ffi/jsc/NativeApiJSCSelectorGroups.mm",
57+
"NativeScript/ffi/v8/NativeApiV8SelectorGroups.mm",
58+
"NativeScript/ffi/quickjs/NativeApiQuickJSSelectorGroups.mm",
59+
"NativeScript/ffi/hermes/NativeApiJsi.mm",
60+
"packages/react-native/native-api/ffi/hermes/NativeApiJsi.mm",
61+
]) {
62+
const fullPath = path.join(repoRoot, relativePath);
63+
if (!fs.existsSync(fullPath)) {
64+
continue;
65+
}
66+
const source = fs.readFileSync(fullPath, "utf8");
67+
assert(
68+
source.includes("prepared->isInitMethod") &&
69+
source.includes("preservedNativeApiInitializerSelfReturn("),
70+
`${relativePath}: engine selector groups should preserve the original receiver when init returns self`,
5071
);
5172
}
5273

0 commit comments

Comments
 (0)