Skip to content

Commit 5ae0568

Browse files
DjDeveloperrclaude
andcommitted
react-native(fabric): native proof-of-delivery props elision (Stage 2)
The full serialized _uikitHostPropsJson rides on EVERY host lifecycle crossing (NativeScriptUIView runUIKitHostLifecycle:transactionJson:) and is re-marshalled through a JSI string copy on the worklet side (NativeScriptNativeApiModule's createFromUtf8) even though the JS-side memo (lastNativePropsJson, already landed) makes the *apply* a no-op once JS has these exact bytes. Adds _lastDeliveredUikitHostPropsJson: the props string last DELIVERED where a crossing proved (via a non-nil handles return) that JS had a live registration to memo it on. The per-crossing call now elides props (sends nil) only when the current _uikitHostPropsJson is byte-identical to that marker; JS already treats empty/missing props as a pure no-op (syncUIKitHostPropsFromNative's typeof guard), so elision is outcome-identical to today's JS-side memo hit. Fail-open by construction: the marker is set only on a non-nil (proof-of-delivery) handles return and is cleared on any other outcome (create-miss, JS-side exception) so the very next crossing resends unconditionally; it is also cleared on any -setHostId: change (a fresh registration has no memo of its own), which covers -prepareForRecycle since that routes through the hostId setter. No JS changes needed. Extends the structural node test with tripwires for the fail-open reset points (setHostId clear, dealloc release) and the elision predicate (byte comparison of the pending call, never a hostId-keyed side cache). Verified: itest --suite slide (pop-slide 9/10, edge-swipe-slide 5/5, zero strands/rehost/bypass -- matches this session's established noise floor, see the Stage 1 commit), --suite core (11/12, one borderline MAIN_THREAD_STALL_STEADY jitter scenario, same pattern as pristine baseline under load), --suite parity (14/14), --suite reveal (12/12 -- the cold-first create/replay windows this change touches), and the plain-node structural tests (35/38, 3 pre-existing/unrelated). Honest sizing (Stage 0, NS_NS_HOST_PROFILE): for this demo app's crossings, native jsMs already tracked the JS-side "rest" bucket almost exactly (e.g. 82.2ms vs 82.0ms), meaning the props-marshal gap this stage targets was not separately measurable at this app's payload sizes -- the design's hypothesis that this dominates the 250-370ms burst did not hold here. Landed anyway per the design (cheap, fail-open, protects larger payloads); the real remaining cost is adapter-side reconcile/handler execution, out of scope (design doc Sec 6.5/6.6). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e1c7ab1 commit 5ae0568

2 files changed

Lines changed: 115 additions & 4 deletions

File tree

packages/react-native/ios/NativeScriptUIView.mm

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1858,6 +1858,17 @@ @implementation NativeScriptUIView {
18581858
// now-deleted _mountingTransactionToken and
18591859
// _fabricTransactionCommitFallbackToken.
18601860
NSUInteger _fabricTransactionDeliveryToken;
1861+
// Stage 2 (update-phase crossing optimization, native proof-of-delivery
1862+
// props elision): the full serialized _uikitHostPropsJson rides on EVERY
1863+
// host lifecycle crossing and is re-marshalled through a JSI string copy
1864+
// on the worklet side even though the JS memo (lastNativePropsJson) makes
1865+
// the *apply* a no-op when it is unchanged. This remembers the props
1866+
// string last DELIVERED where the crossing proved (via a non-nil handles
1867+
// return) that JS had a live registration to memo it on -- see
1868+
// -runUIKitHostLifecycle:transactionJson: for the elision and every reset
1869+
// point (nil-handle return, -setHostId: change, which also covers
1870+
// -prepareForRecycle since it routes through the hostId setter).
1871+
NSString* _lastDeliveredUikitHostPropsJson;
18611872
}
18621873

18631874
- (instancetype)initWithFrame:(CGRect)frame {
@@ -1911,6 +1922,7 @@ - (void)dealloc {
19111922
[_hostReadyId release];
19121923
[_debugName release];
19131924
[_uikitHostPropsJson release];
1925+
[_lastDeliveredUikitHostPropsJson release];
19141926
[_onHostReady release];
19151927
[_lastDetachedChildrenLayoutKey release];
19161928
[_lastDetachedChildrenDisplayKey release];
@@ -2010,6 +2022,13 @@ - (void)setHostId:(NSString*)hostId {
20102022
_hasCreatedUIKitHost = NO;
20112023
_hasDeliveredMountedLifecycle = NO;
20122024
_hasReplayedFabricTransactionAfterHostCreation = NO;
2025+
// Stage 2 proof-of-delivery marker: a new hostId means a completely fresh
2026+
// JS-side registration (no lastNativePropsJson memo of its own yet), so
2027+
// any elision decision made for the previous hostId is meaningless here --
2028+
// always resend on the next crossing. Also the -prepareForRecycle reset
2029+
// path (it sets hostId to nil, routing through this setter).
2030+
[_lastDeliveredUikitHostPropsJson release];
2031+
_lastDeliveredUikitHostPropsJson = nil;
20132032
[_fabricMountedChildLifecycleKeys removeAllObjects];
20142033
[self setNeedsUIKitHostRefreshAfterNativeAttachment];
20152034
[self invalidateHostReadySnapshot];
@@ -2808,12 +2827,45 @@ - (void)runUIKitHostLifecycle:(NSString*)phase transactionJson:(NSString*)transa
28082827
}
28092828

28102829
[self mountUIKitHostIfNeeded];
2830+
2831+
// Stage 2 (proof-of-delivery props elision): _uikitHostPropsJson is a
2832+
// multi-KB serialized snapshot that otherwise rides on EVERY crossing --
2833+
// re-marshalled through a JSI string copy on the worklet side even though
2834+
// the JS-side memo (lastNativePropsJson) already makes the *apply* a
2835+
// no-op once JS has these exact bytes. Elide it (send nil) only when a
2836+
// PRIOR crossing already delivered these exact bytes AND proved (via a
2837+
// non-nil handles return) that JS had a live registration to memo them
2838+
// on. JS treats empty/missing props as a pure no-op
2839+
// (syncUIKitHostPropsFromNative's typeof guard) -- identical outcome to
2840+
// today's memo hit, so eliding changes nothing JS observes.
2841+
BOOL canElideProps = _lastDeliveredUikitHostPropsJson != nil &&
2842+
((_lastDeliveredUikitHostPropsJson == _uikitHostPropsJson) ||
2843+
[_lastDeliveredUikitHostPropsJson isEqualToString:_uikitHostPropsJson]);
2844+
NSString* propsJsonForCrossing = canElideProps ? nil : _uikitHostPropsJson;
2845+
28112846
NSDictionary<NSString*, NSString*>* handles =
28122847
transactionJson.length > 0
2813-
? NativeScriptRunUIKitHostLifecycleWithInfo(_hostId, phase, _uikitHostPropsJson,
2848+
? NativeScriptRunUIKitHostLifecycleWithInfo(_hostId, phase, propsJsonForCrossing,
28142849
transactionJson, [self nativeMountInfoJson])
2815-
: NativeScriptRunUIKitHostLifecycleWithInfo(_hostId, phase, _uikitHostPropsJson, nil,
2850+
: NativeScriptRunUIKitHostLifecycleWithInfo(_hostId, phase, propsJsonForCrossing, nil,
28162851
[self nativeMountInfoJson]);
2852+
2853+
// Fail open: a non-nil handles return is the delivery receipt (JS ran the
2854+
// crossing against a live registration), so these exact bytes are now
2855+
// known-delivered -- elide them next time. ANY other outcome (nil
2856+
// handles: create-miss, JS-side exception, etc.) clears the marker so the
2857+
// very next crossing resends unconditionally rather than risk a
2858+
// false-elided delivery.
2859+
if (handles != nil) {
2860+
if (_lastDeliveredUikitHostPropsJson != _uikitHostPropsJson) {
2861+
[_lastDeliveredUikitHostPropsJson release];
2862+
_lastDeliveredUikitHostPropsJson = [_uikitHostPropsJson copy];
2863+
}
2864+
} else if (_lastDeliveredUikitHostPropsJson != nil) {
2865+
[_lastDeliveredUikitHostPropsJson release];
2866+
_lastDeliveredUikitHostPropsJson = nil;
2867+
}
2868+
28172869
[self applyUIKitHostHandles:handles];
28182870
}
28192871

packages/react-native/test/uikit-host-native-props-api.test.js

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,17 +171,76 @@ assert(
171171
hostView.includes("_hasCreatedUIKitHost = YES;") &&
172172
hostView.includes("_hostId.length == 0 || _hasCreatedUIKitHost") &&
173173
hostView.includes("NativeScriptRunUIKitHostLifecycleWithInfo(") &&
174+
// Stage 2 (update-phase crossing optimization, native proof-of-delivery
175+
// props elision): the per-crossing lifecycle call now sends
176+
// propsJsonForCrossing (nil when a prior crossing already delivered
177+
// these exact bytes to a live JS registration), not the raw
178+
// _uikitHostPropsJson unconditionally -- see the elision block and its
179+
// reset points below.
174180
normalizedHostView.includes(
175-
"NativeScriptRunUIKitHostLifecycleWithInfo(_hostId, phase, _uikitHostPropsJson, transactionJson, [self nativeMountInfoJson])"
181+
"NativeScriptRunUIKitHostLifecycleWithInfo(_hostId, phase, propsJsonForCrossing, transactionJson, [self nativeMountInfoJson])"
176182
) &&
177183
normalizedHostView.includes(
178-
"NativeScriptRunUIKitHostLifecycleWithInfo(_hostId, phase, _uikitHostPropsJson, nil, [self nativeMountInfoJson])"
184+
"NativeScriptRunUIKitHostLifecycleWithInfo(_hostId, phase, propsJsonForCrossing, nil, [self nativeMountInfoJson])"
179185
) &&
180186
hostView.indexOf("NativeScriptRunUIKitHostLifecycleWithInfo(") >
181187
hostView.indexOf("- (void)runUIKitHostLifecycle:"),
182188
"NativeScriptUIView should forward latest props and native mount info while avoiding already-mounted native host recreation before every lifecycle call",
183189
);
184190

191+
// Stage 2 tripwires: the elision must be provably fail-open (every reset
192+
// point present) and must never be computed from anything but a raw string
193+
// comparison against the bytes native is about to send.
194+
const runLifecycleIndex = hostView.indexOf(
195+
"- (void)runUIKitHostLifecycle:(NSString*)phase transactionJson:",
196+
);
197+
const runLifecycleBody = hostView.slice(
198+
runLifecycleIndex,
199+
hostView.indexOf("\n- (void)runUIKitHostLifecycle:(NSString*)phase {", runLifecycleIndex),
200+
);
201+
202+
assert(
203+
runLifecycleIndex > -1 &&
204+
runLifecycleBody.includes("_lastDeliveredUikitHostPropsJson != nil") &&
205+
runLifecycleBody.includes(
206+
"[_lastDeliveredUikitHostPropsJson isEqualToString:_uikitHostPropsJson]",
207+
) &&
208+
runLifecycleBody.includes(
209+
"NSString* propsJsonForCrossing = canElideProps ? nil : _uikitHostPropsJson;",
210+
),
211+
"the props elision predicate must compare raw bytes on the pending call, never a hostId-keyed side cache",
212+
);
213+
214+
assert(
215+
runLifecycleBody.includes("if (handles != nil) {") &&
216+
runLifecycleBody.includes(
217+
"_lastDeliveredUikitHostPropsJson = [_uikitHostPropsJson copy];",
218+
) &&
219+
runLifecycleBody.includes("} else if (_lastDeliveredUikitHostPropsJson != nil) {") &&
220+
runLifecycleBody.includes("_lastDeliveredUikitHostPropsJson = nil;") &&
221+
runLifecycleBody.indexOf("if (handles != nil) {") <
222+
runLifecycleBody.indexOf("[self applyUIKitHostHandles:handles];"),
223+
"the delivered-props marker must be fail-open: set only on a non-nil (proof-of-delivery) handles return, cleared on any other outcome, before handles are applied",
224+
);
225+
226+
const setHostIdIndex = hostView.indexOf("- (void)setHostId:(NSString*)hostId {");
227+
const setHostIdBody = hostView.slice(
228+
setHostIdIndex,
229+
hostView.indexOf("\n- (void)setHostReadyId:", setHostIdIndex),
230+
);
231+
assert(
232+
setHostIdIndex > -1 &&
233+
setHostIdBody.includes("[_lastDeliveredUikitHostPropsJson release];") &&
234+
setHostIdBody.includes("_lastDeliveredUikitHostPropsJson = nil;"),
235+
"a hostId change (including the -prepareForRecycle reset, which routes through this setter) must clear the delivered-props marker -- a fresh registration has no memo of its own",
236+
);
237+
238+
assert(
239+
hostView.includes("[_lastDeliveredUikitHostPropsJson release];\n}") ||
240+
/dealloc[\s\S]*?_lastDeliveredUikitHostPropsJson release\]/.test(hostView),
241+
"the delivered-props marker ivar must be released in -dealloc (manual reference counting)",
242+
);
243+
185244
assert(
186245
fabricView.includes("newViewProps->uikitHostPropsJson") &&
187246
fabricView.includes("_containerView.uikitHostPropsJson = uikitHostPropsJson") &&

0 commit comments

Comments
 (0)