Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cool-hats-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@qwik.dev/core': patch
---

fix: run `preventdefault:*`, `stoppropagation:*`, and `sync$` event work synchronously in the loader for all nested events
5 changes: 5 additions & 0 deletions .changeset/plain-snakes-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@qwik.dev/core': patch
---

fix: preserve browser event execution order for async lazy-loaded handlers
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Prefer `pnpm build --qwik --qwikrouter --dev` to build qwik and qwik-city faster
pnpm vitest run packages/qwik/src/core/tests/use-task.spec.tsx

# E2E test — single file
pnpm playwright test e2e/qwik-e2e/tests/e2e.events.e2e.ts --project chromium
pnpm playwright test e2e/qwik-e2e/tests/events.e2e.ts --browser=chromium --config e2e/qwik-e2e/playwright.config.ts
```

## Architecture Essentials
Expand Down Expand Up @@ -178,7 +178,8 @@ packages/qwik/src/core/
- Test fixture apps: `e2e/qwik-e2e/apps/`
- Dev server: `e2e/qwik-e2e/dev-server.ts`
- Run: `pnpm test.e2e.chromium`
- Run one: `pnpm playwright test <path> --project chromium`
- Run one: `pnpm playwright test <path> --browser=chromium --config e2e/qwik-e2e/playwright.config.ts`
- Note: this config uses browser selection, not named Playwright projects, so `--project chromium` is incorrect here
- Browsers: Chromium and WebKit enabled (Firefox disabled)
- Additional E2E suites: `e2e/adapters-e2e/`, `e2e/qwik-react-e2e/`

Expand Down
50 changes: 49 additions & 1 deletion e2e/qwik-e2e/apps/e2e/src/components/events/events.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { $, component$, useOnWindow, useSignal, useStore, type QRL } from '@qwik.dev/core';
import { $, component$, useOnWindow, useSignal, useStore } from '@qwik.dev/core';

export const Events = component$(() => {
const rerenderCount = useSignal(0);
Expand All @@ -19,13 +19,16 @@ const EventsParent = component$(() => {
countTransparent: 0,
countWrapped: 0,
countAnchor: 0,
countNestedAnchor: 0,
countNestedButton: 0,
propagationStoppedCount: 0,
passiveRegularClickCount: 0,
passiveClickCount: 0,
passivePreventDefaultCount: 0,
passivePreventDefaultState: 'unset',
passiveDocumentCount: 0,
passiveWindowCount: 0,
hoverOrderLog: '',
});
return (
<>
Expand All @@ -43,6 +46,25 @@ const EventsParent = component$(() => {
</a>
</p>
<div>
<p>
<a
href="/e2e/events-client"
preventdefault:click
id="prevent-default-parent-anchor"
onClick$={() => {
store.countNestedAnchor++;
}}
>
<button
id="prevent-default-child-button"
onClick$={async () => {
store.countNestedButton++;
}}
>
Should not redirect when child button is clicked
</button>
</a>
</p>
<div
onClick$={() => {
throw new Error('event was not stopped');
Expand Down Expand Up @@ -80,7 +102,33 @@ const EventsParent = component$(() => {
<p id="count-transparent">countTransparent: {store.countTransparent}</p>
<p id="count-wrapped">countWrapped: {store.countWrapped}</p>
<p id="count-anchor">countAnchor: {store.countAnchor}</p>
<p id="count-nested-anchor">countNestedAnchor: {store.countNestedAnchor}</p>
<p id="count-nested-button">countNestedButton: {store.countNestedButton}</p>
<p id="count-propagation">countPropagationStopped: {store.propagationStoppedCount}</p>
<div id="hover-order-fixture" style="display:flex;gap:16px">
<div
id="hover-order-red"
style="width:80px;height:80px;background:#d44"
onMouseLeave$={$(async () => {
await new Promise<void>((resolve) => {
setTimeout(resolve, 60);
});
store.hoverOrderLog = store.hoverOrderLog
? `${store.hoverOrderLog}|red mouse out`
: 'red mouse out';
})}
></div>
<div
id="hover-order-blue"
style="width:80px;height:80px;background:#48f"
onMouseOver$={$(() => {
store.hoverOrderLog = store.hoverOrderLog
? `${store.hoverOrderLog}|blue mouse in`
: 'blue mouse in';
})}
></div>
</div>
<p id="hover-order-log">{store.hoverOrderLog}</p>
<div>
<button
id="passive-regular-click"
Expand Down
22 changes: 22 additions & 0 deletions e2e/qwik-e2e/tests/events.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,28 @@ test.describe('events', () => {
await expect(countWrapped).toHaveText('countAnchor: 1');
});

test('should prevent redirect when clicking a child button inside a prevented anchor', async ({
page,
}) => {
const childButton = page.locator('#prevent-default-child-button');

await childButton.click();

await expect(page).toHaveURL(/\/e2e\/events$/);
await expect(page.locator('#count-nested-anchor')).toHaveText('countNestedAnchor: 1');
await expect(page.locator('#count-nested-button')).toHaveText('countNestedButton: 1');
});

test('should preserve mouseleave and mouseover execution order', async ({ page }) => {
const red = page.locator('#hover-order-red');
const blue = page.locator('#hover-order-blue');

await red.hover();
await blue.hover();

await expect(page.locator('#hover-order-log')).toHaveText('red mouse out|blue mouse in');
});

test(`GIVEN "stoppropagation" is set as a attribute
THEN it should stop propagation`, async ({ page }) => {
const stoppedPropagationButton = page.locator('#stop-propagation');
Expand Down
93 changes: 92 additions & 1 deletion packages/qwik/src/core/tests/events.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { domRender, ssrRenderToDom, trigger } from '@qwik.dev/core/testing';
import { describe, expect, it } from 'vitest';
import { component$, Fragment as Component } from '@qwik.dev/core';
import { $, component$, Fragment as Component, inlinedQrl, type QRL } from '@qwik.dev/core';

const debug = false; //true;
Error.stackTraceLimit = 100;

const delayQrl = <T extends Function>(qrl: QRL<T>): QRL<T> => {
return inlinedQrl(
Promise.resolve((qrl as any).resolve()),
'd_' + (qrl as any).$symbol$,
(qrl as any).$captures$ as any
) as any;
};

describe.each([
{ render: ssrRenderToDom }, //
{ render: domRender }, //
Expand Down Expand Up @@ -141,4 +149,87 @@ describe.each([
expect((globalThis as any).logs).toEqual(['parent capture']);
(globalThis as any).logs = undefined;
});

it('applies parent preventdefault when a child button click bubbles through an anchor', async () => {
(globalThis as any).logs = [];
const Cmp = component$(() => {
return (
<a
href="https://qwik.dev"
preventdefault:click
onClick$={() => {
(globalThis as any).logs.push('parent');
}}
>
<button
onClick$={async () => {
(globalThis as any).logs.push('child');
}}
></button>
</a>
);
});

const { document } = await render(<Cmp />, { debug });
const ev = await trigger(document.body, 'button', 'click');

expect(ev?.defaultPrevented).toBe(true);
expect((globalThis as any).logs).toEqual(['child', 'parent']);
(globalThis as any).logs = undefined;
});
});

// delayed handlers can be only for ssr
it('preserves mouseleave and mouseover execution order when delayed qrls resolve out of order', async () => {
(globalThis as any).logs = [];
(globalThis as any).leavePromise = new Promise<void>((resolve) => {
(globalThis as any).resolveLeave = resolve;
});
(globalThis as any).overPromise = new Promise<void>((resolve) => {
(globalThis as any).resolveOver = resolve;
});

const leaveQrl = delayQrl(
$(async () => {
await (globalThis as any).leavePromise;
(globalThis as any).logs.push('red mouse out');
})
);
const overQrl = delayQrl(
$(async () => {
await (globalThis as any).overPromise;
(globalThis as any).logs.push('blue mouse in');
})
);

const Cmp = component$(() => {
return (
<div>
<div onMouseLeave$={leaveQrl}></div>
<div onMouseOver$={overQrl}></div>
</div>
);
});

const { document } = await ssrRenderToDom(<Cmp />, { debug });
const divs = document.querySelectorAll('div');
const red = divs[1]!;
const blue = divs[2]!;

const leave = trigger(document.body, red, 'mouseleave', {}, { waitForIdle: false });
const over = trigger(document.body, blue, 'mouseover', {}, { waitForIdle: false });

(globalThis as any).resolveOver();
await Promise.resolve();
expect((globalThis as any).logs).toEqual([]);

(globalThis as any).resolveLeave();
await Promise.all([leave, over]);
expect((globalThis as any).logs).toEqual(['red mouse out', 'blue mouse in']);

(globalThis as any).logs = undefined;
(globalThis as any).leavePromise = undefined;
(globalThis as any).overPromise = undefined;
(globalThis as any).resolveLeave = undefined;
(globalThis as any).resolveOver = undefined;
});
29 changes: 29 additions & 0 deletions packages/qwik/src/core/tests/sync-qrl.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,33 @@ describe.each([
await trigger(document.body, input, 'click');
expect(input?.getAttribute('prevented')).toBe('true');
});

it('should run parent sync qrls when a child async click bubbles', async () => {
const Cmp = component$(() => {
return (
<div
onClick$={[
sync$((_event: Event, target: Element) => {
target.setAttribute('parent-sync', 'true');
}),
]}
>
<button
onClick$={async (_event: Event, target: Element) => {
target.setAttribute('child-async', 'true');
}}
></button>
</div>
);
});

const { document } = await render(<Cmp />, { debug });
const button = document.querySelector('button');
const parent = document.querySelector('div');

await trigger(document.body, button, 'click');

expect(button?.getAttribute('child-async')).toBe('true');
expect(parent?.getAttribute('parent-sync')).toBe('true');
});
});
Loading
Loading