diff --git a/.changeset/fix-testing-library-tap-bubbles.md b/.changeset/fix-testing-library-tap-bubbles.md new file mode 100644 index 0000000000..621cb64b0a --- /dev/null +++ b/.changeset/fix-testing-library-tap-bubbles.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/react": patch +--- + +Default `fireEvent` to `bubbles: true` for the TouchEvent family in testing-library to match Lynx runtime semantics, and stop reassigning the read-only `Event.prototype` accessors which threw `TypeError` in strict mode. diff --git a/packages/react/testing-library/src/__tests__/events.test.jsx b/packages/react/testing-library/src/__tests__/events.test.jsx index aadd515919..3f1515ae99 100644 --- a/packages/react/testing-library/src/__tests__/events.test.jsx +++ b/packages/react/testing-library/src/__tests__/events.test.jsx @@ -181,6 +181,155 @@ test('calling `fireEvent` directly works too', () => { `); }); +// https://lynxjs.org/api/elements/built-in/event.html#event-handler-property +// +// | Type | Phase | Intercepts? | +// | -------------- | ------- | ----------- | +// | bind | bubble | no | +// | catch | bubble | yes | +// | capture-bind | capture | no | +// | capture-catch | capture | yes | +// +// Each Lynx event type maps to a separate DOM event name in the testing library +// (e.g. `bindEvent:tap`, `catchEvent:tap`, `capture-bind:tap`, `capture-catch:tap`), +// so "intercept" semantics only apply within the same Lynx event type. +describe('Event handler property semantics', () => { + it('bind: handler runs in bubble phase, does not intercept bubbling', () => { + const calls = []; + const childRef = createRef(); + + const Comp = () => ( + calls.push('parent')}> + calls.push('child')} /> + + ); + render(); + + fireEvent.tap(childRef.current); + + // bubble phase walks target → root, so child fires before parent + expect(calls).toEqual(['child', 'parent']); + }); + + it('catch: handler runs in bubble phase and stops further propagation', () => { + const parent = vi.fn(); + const child = vi.fn(); + const childRef = createRef(); + + const Comp = () => ( + + + + ); + render(); + + fireEvent.tap(childRef.current, { eventType: 'catchEvent', bubbles: true }); + + expect(child).toHaveBeenCalledTimes(1); + expect(parent).toHaveBeenCalledTimes(0); + }); + + it('capture-bind: handler runs in capture phase, does not intercept', () => { + const calls = []; + const childRef = createRef(); + + const Comp = () => ( + calls.push('parent') }}> + calls.push('child') }} + /> + + ); + render(); + + fireEvent.tap(childRef.current, { eventType: 'capture-bind' }); + + // capture phase walks root → target, so parent fires before child + expect(calls).toEqual(['parent', 'child']); + }); + + it('capture-catch: handler runs in capture phase and stops further propagation', () => { + const parent = vi.fn(); + const child = vi.fn(); + const childRef = createRef(); + + const Comp = () => ( + + + + ); + render(); + + fireEvent.tap(childRef.current, { eventType: 'capture-catch' }); + + // parent fires first in capture phase, calls stopPropagation, + // so the event never reaches the child target + expect(parent).toHaveBeenCalledTimes(1); + expect(child).toHaveBeenCalledTimes(0); + }); + + it('capture phase fires regardless of bubbles=false', () => { + const parent = vi.fn(); + const childRef = createRef(); + + const Comp = () => ( + + + + ); + render(); + + fireEvent.tap(childRef.current, { + eventType: 'capture-bind', + bubbles: false, + }); + + expect(parent).toHaveBeenCalledTimes(1); + }); + + it('bind on ancestor needs bubbles=true to be reached from a descendant', () => { + const parent = vi.fn(); + const childRef = createRef(); + + const Comp = () => ( + + + + ); + render(); + + // fireEvent.tap defaults to bubbles: true (matches Lynx runtime) + fireEvent.tap(childRef.current); + expect(parent).toHaveBeenCalledTimes(1); + + // explicit bubbles: false skips the bubble phase, so the ancestor handler does not fire + fireEvent.tap(childRef.current, { bubbles: false }); + expect(parent).toHaveBeenCalledTimes(1); + }); + + // https://lynx.bytedance.net/next/zh/api/lynx-api/event/touch-event.html + // Every TouchEvent-family event (BaseTouchEvent in @lynx-js/types) + // bubbles in Lynx: touch{start,move,end,cancel}, longpress. + it.each(['touchstart', 'touchmove', 'touchend', 'touchcancel', 'longpress'])( + '%s: bubbles to ancestor handlers by default', + (eventName) => { + const parent = vi.fn(); + const childRef = createRef(); + + const Comp = () => ( + + + + ); + render(); + + fireEvent[eventName](childRef.current); + expect(parent).toHaveBeenCalledTimes(1); + }, + ); +}); + test('customEvent not in internal eventMap', () => { const handler = vi.fn(); diff --git a/packages/react/testing-library/src/fire-event.ts b/packages/react/testing-library/src/fire-event.ts index 52f784d89d..f4a5c124f1 100644 --- a/packages/react/testing-library/src/fire-event.ts +++ b/packages/react/testing-library/src/fire-event.ts @@ -38,12 +38,13 @@ export const fireEvent: any = (elemOrNodesRef, ...args) => { }; export const eventMap = { - // LynxBindCatchEvent Events + // LynxBindCatchEvent — TouchEvent family, bubble/capture per + // https://lynx.bytedance.net/next/zh/api/lynx-api/event/touch-event.html tap: { - defaultInit: {}, + defaultInit: { bubbles: true }, }, longtap: { - defaultInit: {}, + defaultInit: { bubbles: true }, }, // LynxEvent Events bgload: { @@ -52,20 +53,24 @@ export const eventMap = { bgerror: { defaultInit: {}, }, + // TouchEvent family — every event whose handler signature is + // `EventHandler>` in @lynx-js/types bubbles. Other + // LynxEvent entries (animation/transition/mouse/wheel/key/focus/blur/ + // layout/image) are component-local and do not propagate. touchstart: { - defaultInit: {}, + defaultInit: { bubbles: true }, }, touchmove: { - defaultInit: {}, + defaultInit: { bubbles: true }, }, touchcancel: { - defaultInit: {}, + defaultInit: { bubbles: true }, }, touchend: { - defaultInit: {}, + defaultInit: { bubbles: true }, }, longpress: { - defaultInit: {}, + defaultInit: { bubbles: true }, }, transitionstart: { defaultInit: {}, @@ -171,7 +176,11 @@ Object.keys(eventMap).forEach((key) => { elem, init, ); - Object.assign(event, init); + // `bubbles`, `cancelable`, `composed` are read-only accessors on Event.prototype. + // They're already applied via the EventInit dict above; assigning them again + // throws in strict mode. + const { bubbles, cancelable, composed, ...assignableInit } = init; + Object.assign(event, assignableInit); const ans = domFireEvent( elem, event,