Skip to content

Commit 7d7e9d3

Browse files
committed
fix: persist JS subclass instance fields
1 parent b016ae8 commit 7d7e9d3

4 files changed

Lines changed: 114 additions & 0 deletions

File tree

NativeScript/ffi/shared/bridge/HostObjects.mm

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1354,6 +1354,48 @@ bool invokeEnginePrototypeSetter(Runtime& runtime, const std::string& property,
13541354
return false;
13551355
}
13561356

1357+
bool enginePrototypeHasSetter(Runtime& runtime,
1358+
const std::string& property) {
1359+
if (object_ == nil || property.empty()) {
1360+
return false;
1361+
}
1362+
Value classWrapperValue =
1363+
bridge_->findObjectExpando(runtime, object_, "__nativeApiClassWrapper");
1364+
if (!classWrapperValue.isObject()) {
1365+
classWrapperValue =
1366+
bridge_->findClassValue(runtime, object_getClass(object_));
1367+
}
1368+
if (!classWrapperValue.isObject()) {
1369+
return false;
1370+
}
1371+
Value prototypeValue =
1372+
classWrapperValue.asObject(runtime).getProperty(runtime, "prototype");
1373+
if (!prototypeValue.isObject()) {
1374+
return false;
1375+
}
1376+
Object objectConstructor =
1377+
runtime.global().getPropertyAsObject(runtime, "Object");
1378+
Function getOwnPropertyDescriptor =
1379+
objectConstructor.getPropertyAsFunction(runtime, "getOwnPropertyDescriptor");
1380+
Function getPrototypeOf =
1381+
objectConstructor.getPropertyAsFunction(runtime, "getPrototypeOf");
1382+
Value propertyName = makeString(runtime, property);
1383+
Value currentValue(runtime, prototypeValue);
1384+
for (size_t depth = 0; depth < 64 && currentValue.isObject(); depth++) {
1385+
Object current = currentValue.asObject(runtime);
1386+
Value descriptorValue = getOwnPropertyDescriptor.call(
1387+
runtime, Value(runtime, current), propertyName);
1388+
if (descriptorValue.isObject()) {
1389+
Value setterValue =
1390+
descriptorValue.asObject(runtime).getProperty(runtime, "set");
1391+
return setterValue.isObject() &&
1392+
setterValue.asObject(runtime).isFunction(runtime);
1393+
}
1394+
currentValue = getPrototypeOf.call(runtime, Value(runtime, current));
1395+
}
1396+
return false;
1397+
}
1398+
13571399
Value get(Runtime& runtime, const PropNameID& name) override {
13581400
std::string property = name.utf8(runtime);
13591401

@@ -1903,6 +1945,9 @@ throw JSError(
19031945
}
19041946
NATIVE_API_SET_RETURN(true);
19051947
#else
1948+
if (!enginePrototypeHasSetter(runtime, property)) {
1949+
storeOwnExpando(runtime, property, value);
1950+
}
19061951
NATIVE_API_SET_RETURN(false);
19071952
#endif
19081953
}

PROGRESS.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,35 @@ TypeScript/UI worklets.
5151
- If CI proves the runtime fix, clean the diagnostic history before resuming
5252
the dedicated simulator-only RNS parity sweep.
5353

54+
### 2026-06-29 21:37 EDT - JS subclass fields mirrored across wrapper churn
55+
56+
- CI finding:
57+
- GitHub Actions run `28413556555` on `b016ae80` compiled the edited runtime
58+
sources but still failed in macOS tests before iOS ran.
59+
- `MethodCallsTests.js Override: More than one methods with same jsname`
60+
lost `this.zeroArgs` and `this.x` across a native callback, and
61+
`ApiTests.js NSMutableArrayMethods` still lost `_array` during native
62+
`addObject:`.
63+
- Both failures point to JS-owned instance fields being attached to a
64+
transient wrapper instead of surviving native callback/re-wrap paths.
65+
- Changes:
66+
- For JS subclass instances on engines that can defer host-object sets to the
67+
normal JS prototype chain, unknown field writes are now mirrored into native
68+
object expandos when the JS prototype chain does not define a setter.
69+
- Prototype setters still win; the fallback only mirrors plain instance state
70+
such as `_array`, `zeroArgs`, and `x`.
71+
- Added a source guard for this expando fallback.
72+
- Verification:
73+
- Runtime RN JS tests passed:
74+
`for f in packages/react-native/test/*.test.js; do node "$f" || exit 1; done`.
75+
- `git diff --check` passed.
76+
- `npm run check:ffi-boundaries` passed.
77+
- `npm run build:macos-cli` passed.
78+
- Still next:
79+
- Commit/push the JS subclass expando fallback and watch fresh PR CI.
80+
- CI must prove macOS `MethodCallsTests` and `NSMutableArrayMethods` before
81+
returning to RNS simulator parity work.
82+
5483
### 2026-06-29 18:19 EDT - indexed collection subclass aliases
5584

5685
- Goal:

RN_API.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,19 @@ UIKit-backed React Native libraries in TypeScript/UI worklets.
5555
- If native initialization substitutes a different Objective-C object, the
5656
bridge must keep returning the substituted object wrapper.
5757

58+
## 2026-06-29 JS Subclass Instance Field Persistence
59+
60+
- No new public NativeScript React Native API was added.
61+
- Generic native bridge behavior was clarified for JS/TypeScript native
62+
subclasses:
63+
- Plain JS-owned instance fields set on a native subclass wrapper must survive
64+
native callback/re-wrap paths for the same Objective-C receiver.
65+
- On engines that defer host-object sets to normal JS property semantics, the
66+
bridge mirrors unknown writes into native object expandos when the JS
67+
prototype chain does not define a setter for that property.
68+
- Prototype setters remain authoritative and must not be shadowed by the
69+
expando fallback.
70+
5871
## 2026-06-29 Simulator Latency Comparison
5972

6073
- No new public NativeScript React Native API was added.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
const assert = require("assert");
2+
const fs = require("fs");
3+
const path = require("path");
4+
5+
const repoRoot = path.resolve(__dirname, "../../..");
6+
7+
for (const relativePath of [
8+
"NativeScript/ffi/shared/bridge/HostObjects.mm",
9+
"packages/react-native/native-api/ffi/shared/bridge/HostObjects.mm",
10+
]) {
11+
const source = fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
12+
13+
assert(
14+
source.includes("bool enginePrototypeHasSetter(Runtime& runtime,") &&
15+
source.includes('getPropertyAsFunction(runtime, "getOwnPropertyDescriptor")') &&
16+
source.includes('getPropertyAsFunction(runtime, "getPrototypeOf")') &&
17+
source.includes('getProperty(runtime, "set")'),
18+
`${relativePath}: JS subclass expando fallback should detect prototype setters before caching unknown writes`,
19+
);
20+
21+
assert(
22+
source.includes("#else\n if (!enginePrototypeHasSetter(runtime, property)) {\n storeOwnExpando(runtime, property, value);\n }\n NATIVE_API_SET_RETURN(false);"),
23+
`${relativePath}: engines that defer host sets should still mirror plain JS-owned fields into native expandos`,
24+
);
25+
}
26+
27+
console.log("runtime JS subclass expando tests passed");

0 commit comments

Comments
 (0)