Skip to content

Commit 482ffea

Browse files
committed
fix: reduce redundant updates for handlers and gestures
- Implement copy-on-commit for worklets, gestures, and spread props to avoid background-side mutation. - Prevent _execId churn for stable references, reducing redundant native patches. - Fix gesture removal cleanup when removed from spread props. - Add regression tests for execId churn and gesture cleanup. - Add changeset for @lynx-js/react-runtime.
1 parent 9b69730 commit 482ffea

File tree

7 files changed

+411
-42
lines changed

7 files changed

+411
-42
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@lynx-js/react-runtime": patch
3+
---
4+
5+
fix: reduce redundant updates for main-thread handlers and gestures
6+
7+
- Updates are faster when the main-thread event handler or gesture object is stable across rerenders (fewer unnecessary native updates).
8+
- Spread props rerenders that don't semantically change the handler/gesture no longer trigger redundant updates.
9+
- Removing a gesture from spread props reliably clears the gesture state on the target element.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { prepareGestureForCommit } from '../../src/gesture/processGestureBagkround';
4+
5+
describe('prepareGestureForCommit', () => {
6+
it('does not mutate input gesture and supports non-object callbacks', () => {
7+
const gesture = {
8+
id: 1,
9+
type: 0,
10+
callbacks: {
11+
onUpdate: null,
12+
},
13+
__isGesture: true,
14+
toJSON() {
15+
const { toJSON, ...rest } = this;
16+
return {
17+
...rest,
18+
__isSerialized: true,
19+
};
20+
},
21+
};
22+
23+
const committed = prepareGestureForCommit(gesture);
24+
expect(committed).not.toBe(gesture);
25+
expect(committed.callbacks).not.toBe(gesture.callbacks);
26+
expect(committed.callbacks.onUpdate).toBe(null);
27+
28+
// Committed payload should serialize itself, not rely on the original object's toJSON.
29+
const json = committed.toJSON();
30+
expect(json.__isSerialized).toBe(true);
31+
});
32+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { processGesture } from '../../src/gesture/processGesture';
4+
5+
describe('processGesture', () => {
6+
const originalSetAttribute = globalThis.__SetAttribute;
7+
8+
afterEach(() => {
9+
globalThis.__SetAttribute = originalSetAttribute;
10+
});
11+
12+
it('clears native gesture state when gesture is removed', () => {
13+
const setAttribute = vi.fn();
14+
globalThis.__SetAttribute = setAttribute;
15+
16+
const dom = {};
17+
const oldGesture = {
18+
type: 0,
19+
__isSerialized: true,
20+
};
21+
22+
processGesture(dom, undefined, oldGesture, false);
23+
24+
expect(setAttribute).toHaveBeenCalledWith(dom, 'has-react-gesture', null);
25+
expect(setAttribute).toHaveBeenCalledWith(dom, 'flatten', null);
26+
expect(setAttribute).toHaveBeenCalledWith(dom, 'gesture', null);
27+
});
28+
29+
it('does not clear native state when domSet=true', () => {
30+
const setAttribute = vi.fn();
31+
globalThis.__SetAttribute = setAttribute;
32+
33+
const dom = {};
34+
const oldGesture = {
35+
type: 0,
36+
__isSerialized: true,
37+
};
38+
39+
processGesture(dom, undefined, oldGesture, false, { domSet: true });
40+
expect(setAttribute).not.toHaveBeenCalled();
41+
});
42+
43+
it('does not clear native state when oldGesture is not serialized', () => {
44+
const setAttribute = vi.fn();
45+
globalThis.__SetAttribute = setAttribute;
46+
47+
const dom = {};
48+
const oldGesture = {
49+
type: 0,
50+
__isSerialized: false,
51+
};
52+
53+
processGesture(dom, undefined, oldGesture, false);
54+
expect(setAttribute).not.toHaveBeenCalled();
55+
});
56+
});
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { render } from 'preact';
2+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { useState } from '../../src/index';
5+
import { replaceCommitHook } from '../../src/lifecycle/patch/commit';
6+
import { injectUpdateMainThread } from '../../src/lifecycle/patch/updateMainThread';
7+
import { __root } from '../../src/root';
8+
import { setupPage } from '../../src/snapshot';
9+
import { globalEnvManager } from '../utils/envManager';
10+
import { elementTree, waitSchedule } from '../utils/nativeMethod';
11+
12+
function getSnapshotPatchFromPatchUpdateCall(call) {
13+
const obj = call[1];
14+
const parsed = JSON.parse(obj.data);
15+
return parsed.patchList?.[0]?.snapshotPatch;
16+
}
17+
18+
beforeAll(() => {
19+
setupPage(__CreatePage('0', 0));
20+
injectUpdateMainThread();
21+
replaceCommitHook();
22+
});
23+
24+
beforeEach(() => {
25+
globalEnvManager.resetEnv();
26+
SystemInfo.lynxSdkVersion = '999.999';
27+
});
28+
29+
afterEach(() => {
30+
vi.restoreAllMocks();
31+
elementTree.clear();
32+
});
33+
34+
describe('Patch size / execId churn', () => {
35+
it('MTF: stable ctx reference should not generate snapshotPatch', async function() {
36+
const mtf = {
37+
_wkltId: '835d:450ef:stable',
38+
};
39+
40+
let bump_;
41+
function Comp() {
42+
const [, setTick] = useState(0);
43+
bump_ = () => {
44+
setTick(v => v + 1);
45+
};
46+
return (
47+
<view>
48+
<text main-thread:bindtap={mtf}>1</text>
49+
</view>
50+
);
51+
}
52+
53+
// main thread render
54+
{
55+
__root.__jsx = <Comp />;
56+
renderPage();
57+
}
58+
59+
// background render
60+
{
61+
globalEnvManager.switchToBackground();
62+
render(<Comp />, __root);
63+
}
64+
65+
// hydrate
66+
{
67+
lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
68+
69+
globalEnvManager.switchToMainThread();
70+
const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
71+
globalThis[rLynxChange[0]](rLynxChange[1]);
72+
}
73+
74+
// rerender with no semantic changes
75+
{
76+
globalEnvManager.switchToBackground();
77+
lynx.getNativeApp().callLepusMethod.mockClear();
78+
bump_();
79+
await waitSchedule();
80+
81+
globalEnvManager.switchToMainThread();
82+
const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
83+
expect(getSnapshotPatchFromPatchUpdateCall(rLynxChange)).toBeUndefined();
84+
}
85+
});
86+
87+
it('spread: stable semantics should not generate snapshotPatch', async function() {
88+
let bump_;
89+
function Comp() {
90+
const [, setTick] = useState(0);
91+
bump_ = () => {
92+
setTick(v => v + 1);
93+
};
94+
// Simulate typical compiled output: a fresh ctx object each render.
95+
// `_wkltId` stays the same, but runtime injects `_execId`, causing patch churn.
96+
const spread = {
97+
'main-thread:bindtap': {
98+
_wkltId: '835d:450ef:stable',
99+
},
100+
};
101+
return (
102+
<view>
103+
<text {...spread}>1</text>
104+
</view>
105+
);
106+
}
107+
108+
// main thread render
109+
{
110+
__root.__jsx = <Comp />;
111+
renderPage();
112+
}
113+
114+
// background render
115+
{
116+
globalEnvManager.switchToBackground();
117+
render(<Comp />, __root);
118+
}
119+
120+
// hydrate
121+
{
122+
lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
123+
124+
globalEnvManager.switchToMainThread();
125+
const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
126+
globalThis[rLynxChange[0]](rLynxChange[1]);
127+
}
128+
129+
// rerender with no semantic changes
130+
{
131+
globalEnvManager.switchToBackground();
132+
lynx.getNativeApp().callLepusMethod.mockClear();
133+
bump_();
134+
await waitSchedule();
135+
136+
globalEnvManager.switchToMainThread();
137+
const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
138+
expect(getSnapshotPatchFromPatchUpdateCall(rLynxChange)).toBeUndefined();
139+
}
140+
});
141+
142+
it('gesture: stable gesture reference should not generate snapshotPatch', async function() {
143+
const stableGesture = {
144+
id: 1,
145+
type: 0,
146+
callbacks: {
147+
onUpdate: {
148+
_wkltId: 'bdd4:dd564:stable',
149+
},
150+
},
151+
__isGesture: true,
152+
toJSON() {
153+
const { toJSON, ...rest } = this;
154+
return {
155+
...rest,
156+
__isSerialized: true,
157+
};
158+
},
159+
};
160+
161+
function Comp(_props) {
162+
return (
163+
<view>
164+
<text main-thread:gesture={stableGesture}>1</text>
165+
</view>
166+
);
167+
}
168+
169+
// main thread render
170+
{
171+
__root.__jsx = <Comp tick={0} />;
172+
renderPage();
173+
}
174+
175+
// background render
176+
{
177+
globalEnvManager.switchToBackground();
178+
render(<Comp tick={0} />, __root);
179+
}
180+
181+
// hydrate
182+
{
183+
lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
184+
185+
globalEnvManager.switchToMainThread();
186+
const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
187+
globalThis[rLynxChange[0]](rLynxChange[1]);
188+
}
189+
190+
// rerender with no semantic changes
191+
{
192+
globalEnvManager.switchToBackground();
193+
lynx.getNativeApp().callLepusMethod.mockClear();
194+
render(<Comp tick={1} />, __root);
195+
196+
globalEnvManager.switchToMainThread();
197+
const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
198+
expect(getSnapshotPatchFromPatchUpdateCall(rLynxChange)).toBeUndefined();
199+
}
200+
});
201+
});

0 commit comments

Comments
 (0)