Skip to content

Commit 3202c88

Browse files
authored
feat: add event.stopPropagation (#1835)
Add `event.stopPropagation` and `event.stopImmediatePropagation` in MTS, to help with event propagation control ```typescript function App() { function handleInnerTap(event: MainThread.TouchEvent) { 'main thread'; event.stopPropagation(); // Or stop immediate propagation with // event.stopImmediatePropagation(); } // OuterTap will not be triggered return ( <view main-thread:bindtap={handleOuterTap}> <view main-thread:bindtap={handleInnerTap}> <text>Hello, world</text> </view> </view> ); } ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Event objects now support stopPropagation and stopImmediatePropagation and expose combined propagation result. - **Bug Fixes** - Removed an obsolete runtime warning related to stopPropagation handling. - **Documentation** - Added usage example and changelog guidance; note to update related SDK in lazy-loading setups. - **Tests** - Added tests validating propagation methods, event-result wrapping, and SDK gating. <!-- end of auto-generated comment: release notes by coderabbit.ai --> ## Checklist <!--- Check and mark with an "x" --> - [x] Tests updated (or not required). - [x] Documentation updated (or not required). - [x] Changeset added, and when a BREAKING CHANGE occurs, it needs to be clearly marked (or not required).
1 parent 43f7563 commit 3202c88

File tree

7 files changed

+258
-19
lines changed

7 files changed

+258
-19
lines changed

.changeset/clear-crabs-swim.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@lynx-js/react': patch
3+
---
4+
5+
Add `event.stopPropagation` and `event.stopImmediatePropagation` in MTS, to help with event propagation control
6+
7+
```tsx
8+
function App() {
9+
function handleInnerTap(event: MainThread.TouchEvent) {
10+
'main thread';
11+
event.stopPropagation();
12+
// Or stop immediate propagation with
13+
// event.stopImmediatePropagation();
14+
}
15+
16+
// OuterTap will not be triggered
17+
return (
18+
<view main-thread:bindtap={handleOuterTap}>
19+
<view main-thread:bindtap={handleInnerTap}>
20+
<text>Hello, world</text>
21+
</view>
22+
</view>
23+
);
24+
}
25+
```
26+
27+
Note, if this feature is used in [Lazy Loading Standalone Project](https://lynxjs.org/react/code-splitting.html#lazy-loading-standalone-project), both the Producer and the Consumer should update to latest version of `@lynx-js/react` to make sure the feature is available.

packages/react/transform/crates/swc_plugin_compat/lib.rs

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,22 +1043,8 @@ where
10431043
}
10441044

10451045
fn visit_mut_call_expr(&mut self, n: &mut CallExpr) {
1046-
// check if it is e.stopPropagation()
10471046
if let Callee::Expr(e) = &mut n.callee {
10481047
if let Expr::Member(m) = &**e {
1049-
// check if it is e.stopPropagation
1050-
if let MemberProp::Ident(id) = &m.prop {
1051-
if id.sym == "stopPropagation" {
1052-
HANDLER.with(|handler| {
1053-
handler
1054-
.struct_span_warn(
1055-
n.span,
1056-
"BROKEN: e.stopPropagation() takes no effect and MUST be migrated in ReactLynx 3.0",
1057-
)
1058-
.emit()
1059-
});
1060-
}
1061-
}
10621048
if let Expr::This(_) = &*m.obj {
10631049
if let MemberProp::Ident(id) = &m.prop {
10641050
match id.sym.to_string().as_str() {
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright 2025 The Lynx Authors. All rights reserved.
2+
// Licensed under the Apache License Version 2.0 that can be found in the
3+
// LICENSE file in the root directory of this source tree.
4+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
5+
6+
import { initApiEnv } from '../src/api/lynxApi';
7+
import { RunWorkletSource } from '../src/bindings/types';
8+
import { initWorklet } from '../src/workletRuntime';
9+
10+
describe('EventPropagation', () => {
11+
const consoleMock = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
12+
13+
beforeEach(() => {
14+
globalThis.SystemInfo = {
15+
lynxSdkVersion: '3.5',
16+
};
17+
delete globalThis.lynxWorkletImpl;
18+
globalThis.lynx = {
19+
requestAnimationFrame: vi.fn(),
20+
};
21+
initApiEnv();
22+
});
23+
24+
afterAll(() => {
25+
consoleMock.mockReset();
26+
});
27+
28+
it('stopPropagation should have __EventReturnResult be 1', async () => {
29+
initWorklet();
30+
const fn = vi.fn(function(event) {
31+
globalThis.lynxWorkletImpl._workletMap['1'].bind(this);
32+
33+
event.stopPropagation();
34+
});
35+
globalThis.registerWorklet('main-thread', '1', fn);
36+
const ret = globalThis.runWorklet({ _wkltId: '1' }, [{
37+
target: {},
38+
currentTarget: {},
39+
}], {
40+
source: RunWorkletSource.EVENT,
41+
});
42+
expect(ret).toMatchObject({
43+
returnValue: undefined,
44+
eventReturnResult: 1,
45+
});
46+
});
47+
48+
it('stopImmediatePropagation should have __EventReturnResult be 2', async () => {
49+
initWorklet();
50+
const fn = vi.fn(function(event) {
51+
globalThis.lynxWorkletImpl._workletMap['1'].bind(this);
52+
event.stopImmediatePropagation();
53+
});
54+
55+
globalThis.registerWorklet('main-thread', '1', fn);
56+
const ret = globalThis.runWorklet({ _wkltId: '1' }, [{
57+
target: {},
58+
currentTarget: {},
59+
}], {
60+
source: RunWorkletSource.EVENT,
61+
});
62+
expect(ret).toMatchObject({
63+
returnValue: undefined,
64+
eventReturnResult: 2,
65+
});
66+
});
67+
68+
it('call stopPropagation and stopImmediatePropagation should have __EventReturnResult be 3', async () => {
69+
initWorklet();
70+
const fn = vi.fn(function(event) {
71+
globalThis.lynxWorkletImpl._workletMap['1'].bind(this);
72+
event.stopImmediatePropagation();
73+
event.stopPropagation();
74+
});
75+
76+
globalThis.registerWorklet('main-thread', '1', fn);
77+
const ret = globalThis.runWorklet({ _wkltId: '1' }, [{
78+
target: {},
79+
currentTarget: {},
80+
}], {
81+
source: RunWorkletSource.EVENT,
82+
});
83+
expect(ret).toMatchObject({
84+
returnValue: undefined,
85+
eventReturnResult: 0x1 | 0x2,
86+
});
87+
});
88+
});

packages/react/worklet-runtime/__test__/workletRuntime.test.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
55

66
import { Element } from '../src/api/element';
77
import { initApiEnv } from '../src/api/lynxApi';
8+
import { RunWorkletSource } from '../src/bindings/types';
89
import { updateWorkletRefInitValueChanges } from '../src/workletRef';
910
import { initWorklet } from '../src/workletRuntime';
1011

@@ -336,6 +337,58 @@ describe('Worklet', () => {
336337
globalThis.runWorklet(worklet2Ctx, [2]);
337338
expect(worklet2).toBeCalledWith(2);
338339
});
340+
341+
it('event object should have stopPropagation and stopImmediatePropagation', async () => {
342+
initWorklet();
343+
const fn = vi.fn(function(event) {
344+
globalThis.lynxWorkletImpl._workletMap['1'].bind(this);
345+
expect(event.stopPropagation).toBeDefined();
346+
expect(event.stopImmediatePropagation).toBeDefined();
347+
});
348+
globalThis.registerWorklet('main-thread', '1', fn);
349+
globalThis.runWorklet({ _wkltId: '1' }, [{
350+
target: {},
351+
currentTarget: {},
352+
}], {
353+
source: RunWorkletSource.EVENT,
354+
});
355+
});
356+
357+
it('event object should have returnValue wrapped', async () => {
358+
initWorklet();
359+
const fn = vi.fn(function() {
360+
globalThis.lynxWorkletImpl._workletMap['1'].bind(this);
361+
362+
return 1;
363+
});
364+
365+
globalThis.registerWorklet('main-thread', '1', fn);
366+
const ret = globalThis.runWorklet({ _wkltId: '1' }, [{
367+
target: {},
368+
currentTarget: {},
369+
}], {
370+
source: RunWorkletSource.EVENT,
371+
});
372+
373+
expect(ret).toMatchObject({
374+
returnValue: 1,
375+
eventReturnResult: undefined,
376+
});
377+
});
378+
379+
it('non event object should not have returnValue wrapped', async () => {
380+
initWorklet();
381+
const fn = vi.fn(function() {
382+
globalThis.lynxWorkletImpl._workletMap['1'].bind(this);
383+
384+
return 1;
385+
});
386+
387+
globalThis.registerWorklet('main-thread', '1', fn);
388+
const ret = globalThis.runWorklet({ _wkltId: '1' }, [1, 2]);
389+
390+
expect(ret).toBe(1);
391+
});
339392
});
340393

341394
it('requestAnimationFrame should throw error before 2.16', async () => {

packages/react/worklet-runtime/src/bindings/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,17 @@ export interface JsFnHandle {
6666
*/
6767
_delayIndices?: number[];
6868
}
69+
70+
export interface EventCtx {
71+
_eventReturnResult?: number;
72+
}
73+
74+
export enum RunWorkletSource {
75+
NONE = 0,
76+
EVENT = 1,
77+
GESTURE = 2,
78+
}
79+
80+
export interface RunWorkletOptions {
81+
source: RunWorkletSource;
82+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright 2024 The Lynx Authors. All rights reserved.
2+
// Licensed under the Apache License Version 2.0 that can be found in the
3+
// LICENSE file in the root directory of this source tree.
4+
5+
import type { ClosureValueType, EventCtx, RunWorkletOptions } from './bindings/types.js';
6+
import { RunWorkletSource } from './bindings/types.js';
7+
8+
// EventResult enum values
9+
export const EventResult = {
10+
kDefault: 0x0,
11+
kStopPropagationMask: 0x1,
12+
kStopImmediatePropagationMask: 0x2,
13+
} as const;
14+
15+
type EventLike = Record<string, ClosureValueType>;
16+
17+
export function isEventObject(
18+
ctx: ClosureValueType[],
19+
options?: RunWorkletOptions,
20+
): ctx is [EventLike, ...ClosureValueType[]] {
21+
if (!Array.isArray(ctx) || typeof ctx[0] !== 'object' || ctx[0] === null) {
22+
return false;
23+
}
24+
if (options && options.source === RunWorkletSource.EVENT) {
25+
return true;
26+
}
27+
return false;
28+
}
29+
30+
/**
31+
* Add event methods to an event object if needed
32+
* @param ctx The event object to enhance
33+
* @param options The run worklet options
34+
* @returns A tuple of boolean and the event return result
35+
*/
36+
export function addEventMethodsIfNeeded(ctx: ClosureValueType[], options?: RunWorkletOptions): [boolean, EventCtx] {
37+
if (!isEventObject(ctx, options)) {
38+
return [false, {}];
39+
}
40+
const eventCtx: EventCtx = {};
41+
const eventObj = ctx[0];
42+
43+
// Add stopPropagation method
44+
eventObj['stopPropagation'] = function() {
45+
eventCtx._eventReturnResult = (eventCtx._eventReturnResult ?? EventResult.kDefault)
46+
| EventResult.kStopPropagationMask;
47+
};
48+
49+
// Add stopImmediatePropagation method
50+
eventObj['stopImmediatePropagation'] = function() {
51+
eventCtx._eventReturnResult = (eventCtx._eventReturnResult ?? EventResult.kDefault)
52+
| EventResult.kStopImmediatePropagationMask;
53+
};
54+
55+
return [true, eventCtx];
56+
}

packages/react/worklet-runtime/src/workletRuntime.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
// Licensed under the Apache License Version 2.0 that can be found in the
33
// LICENSE file in the root directory of this source tree.
44
import { Element } from './api/element.js';
5-
import type { ClosureValueType, Worklet, WorkletRefImpl } from './bindings/types.js';
5+
import type { ClosureValueType, RunWorkletOptions, Worklet, WorkletRefImpl } from './bindings/types.js';
66
import { initRunOnBackgroundDelay } from './delayRunOnBackground.js';
77
import { delayExecUntilJsReady, initEventDelay } from './delayWorkletEvent.js';
88
import { initEomImpl } from './eomImpl.js';
9+
import { addEventMethodsIfNeeded } from './eventPropagation.js';
910
import { hydrateCtx } from './hydrate.js';
1011
import { JsFunctionLifecycleManager, isRunOnBackgroundEnabled } from './jsFunctionLifecycle.js';
1112
import { runRunOnMainThreadTask } from './runOnMainThread.js';
@@ -48,8 +49,9 @@ function registerWorklet(_type: string, id: string, worklet: (...args: unknown[]
4849
* Native event touch handler will call this function.
4950
* @param ctx worklet object.
5051
* @param params worklet params.
52+
* @param options run worklet options.
5153
*/
52-
function runWorklet(ctx: Worklet, params: ClosureValueType[]): unknown {
54+
function runWorklet(ctx: Worklet, params: ClosureValueType[], options?: RunWorkletOptions): unknown {
5355
if (!validateWorklet(ctx)) {
5456
console.warn('Worklet: Invalid worklet object: ' + JSON.stringify(ctx));
5557
return;
@@ -58,10 +60,10 @@ function runWorklet(ctx: Worklet, params: ClosureValueType[]): unknown {
5860
delayExecUntilJsReady(ctx._lepusWorkletHash, params);
5961
return;
6062
}
61-
return runWorkletImpl(ctx, params);
63+
return runWorkletImpl(ctx, params, options);
6264
}
6365

64-
function runWorkletImpl(ctx: Worklet, params: ClosureValueType[]): unknown {
66+
function runWorkletImpl(ctx: Worklet, params: ClosureValueType[], options?: RunWorkletOptions): unknown {
6567
const worklet: (...args: unknown[]) => unknown = profile(
6668
'transformWorkletCtx ' + ctx._wkltId,
6769
() => transformWorklet(ctx, true),
@@ -70,7 +72,19 @@ function runWorkletImpl(ctx: Worklet, params: ClosureValueType[]): unknown {
7072
'transformWorkletParams',
7173
() => transformWorklet(params || [], false),
7274
);
73-
return profile('runWorklet', () => worklet(...params_));
75+
76+
const [hasEventMethods, eventCtx] = addEventMethodsIfNeeded(params_, options);
77+
78+
const result = profile('runWorklet', () => worklet(...params_));
79+
80+
if (hasEventMethods) {
81+
return {
82+
returnValue: result,
83+
eventReturnResult: eventCtx._eventReturnResult,
84+
};
85+
}
86+
87+
return result;
7488
}
7589

7690
function validateWorklet(ctx: unknown): ctx is Worklet {
@@ -107,6 +121,7 @@ function transformWorklet(
107121
if (isWorklet) {
108122
workletCache.set(ctx, worklet.main);
109123
}
124+
110125
return worklet.main;
111126
}
112127

0 commit comments

Comments
 (0)