Skip to content
Draft
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
68 changes: 67 additions & 1 deletion packages/core/src/event_delegation_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,26 @@

// tslint:disable:no-duplicate-imports
import type {EventContract} from '../primitives/event-dispatch';
import {Attribute} from '../primitives/event-dispatch';
import {Attribute, EventPhase} from '../primitives/event-dispatch';
import {APP_ID} from './application/application_tokens';
import {InjectionToken} from './di';
import type {RElement, RNode} from './render3/interfaces/renderer_dom';
import {INJECTOR, type LView} from './render3/interfaces/view';

export const DEFER_BLOCK_SSR_ID_ATTRIBUTE = 'ngb';

/**
* A symbol used to track which event types have been replayed on a given element via
* `invokeListeners`. This prevents the native DOM listener (registered by the `listener`
* instruction in templates) from also firing for an event that was already handled by
* the event-replay mechanism, which would cause the handler to be called twice.
*/
const REPLAYED_EVENTS_KEY: unique symbol = /* @__PURE__ */ Symbol('ngReplayedEvents');

declare global {
interface Element {
__jsaction_fns: Map<string, Function[]> | undefined;
[REPLAYED_EVENTS_KEY]?: Map<string, number>;
}
}

Expand Down Expand Up @@ -106,11 +115,68 @@ export function invokeListeners(event: Event, currentTarget: Element | null) {
if (!handlerFns || !currentTarget?.isConnected) {
return;
}
// Track that we're about to replay this event so the native DOM listener can detect
// it was already handled and skip itself.
const replayedEvents = currentTarget[REPLAYED_EVENTS_KEY] ?? new Map<string, number>();
replayedEvents.set(event.type, (replayedEvents.get(event.type) ?? 0) + 1);
currentTarget[REPLAYED_EVENTS_KEY] = replayedEvents;
for (const handler of handlerFns) {
handler(event);
}
}

// Elements whose resources load independently and can fire `load`/`error` on their own
// after hydration. Other elements (div, span, etc.) only get events from user interaction,
// so they don't need this treatment.
const AUTO_LOAD_ELEMENTS = /^(IMG|IFRAME|SCRIPT|LINK|OBJECT|EMBED|INPUT)$/;

// Guards against calling enableSkipNativeListenerImpl more than once.
let isSkipNativeListenerImplEnabled = false;

let _shouldSkipNativeListenerImpl: (event: Event) => boolean = () => false;

/**
* Returns whether the native DOM listener should be skipped for this event because
* `invokeListeners` already called it during replay.
*
* Defaults to `() => false` so that terser/esbuild can inline the constant and drop
* the branch in apps that don't use event replay. The real implementation is swapped
* in by `enableSkipNativeListenerImpl` when `withEventReplay()` is configured.
*/
export function shouldSkipNativeListener(event: Event): boolean {
return _shouldSkipNativeListenerImpl(event);
}

/**
* Installs the real `shouldSkipNativeListener` implementation. Called by `withEventReplay()`
* so the logic is only included in bundles that actually need event replay.
*/
export function enableSkipNativeListenerImpl(): void {
if (!isSkipNativeListenerImplEnabled) {
_shouldSkipNativeListenerImpl = (event: Event): boolean => {
// The replay invocation arrives with eventPhase === EventPhase.REPLAY — let it through.
// We only want to suppress the native DOM listener that fires afterwards.
if (event.eventPhase === EventPhase.REPLAY) return false;

const target = (event.currentTarget ?? event.target) as Element | null;
// Only relevant for elements that load resources on their own. For everything else
// (div, button, etc.) the native listener should always fire normally.
if (!target || !AUTO_LOAD_ELEMENTS.test(target.tagName)) {
return false;
}

const map = target[REPLAYED_EVENTS_KEY];
const count = map?.get(event.type);
if (!count) return false;

count <= 1 ? map!.delete(event.type) : map!.set(event.type, count - 1);
return true;
};

isSkipNativeListenerImplEnabled = true;
}
}

/** Shorthand for an event listener callback function to reduce duplication. */
export type EventCallback = (event?: any) => any;

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/hydration/event_replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
invokeListeners,
removeListeners,
enableStashEventListenerImpl,
enableSkipNativeListenerImpl,
setStashFn,
} from '../event_delegation_utils';
import {APP_ID} from '../application/application_tokens';
Expand Down Expand Up @@ -108,6 +109,7 @@ export function withEventReplay(): Provider[] {
const jsActionMap = inject(JSACTION_BLOCK_ELEMENT_MAP);
if (shouldEnableEventReplay(injector)) {
enableStashEventListenerImpl();
enableSkipNativeListenerImpl();
const appId = injector.get(APP_ID);
const clearStashFn = setStashFn(
appId,
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/render3/view/listeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {assertNotSame} from '../../util/assert';
import {handleUncaughtError} from '../instructions/shared';
import {
type EventCallback,
shouldSkipNativeListener,
stashEventListenerImpl,
type WrappedEventCallback,
} from '../../event_delegation_utils';
Expand All @@ -50,6 +51,13 @@ export function wrapListener(
// Note: we are performing most of the work in the listener function itself
// to optimize listener registration.
return function wrapListenerIn_markDirtyAndPreventDefault(event: any) {
// During event replay, `invokeListeners` calls this function directly. Elements like
// `<img>` may then fire a native `load` event on their own — skip it so the handler
// doesn't run twice. Without event replay, `shouldSkipNativeListener` is a `() => false`
// stub that terser/esbuild inline away, so there's no overhead.
if (shouldSkipNativeListener(event)) {
return false;
}
// In order to be backwards compatible with View Engine, events on component host nodes
// must also mark the component view itself dirty (i.e. the view that it owns).
const startView = isComponentHost(tNode) ? getComponentLViewByIndex(tNode.index, lView) : lView;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"APP_ID",
"APP_ID_ATTRIBUTE_NAME",
"APP_INITIALIZER",
"AUTO_LOAD_ELEMENTS",
"ActionResolver",
"AfterRenderImpl",
"AfterRenderManager",
Expand Down Expand Up @@ -245,6 +246,7 @@
"REMOVE_STYLES_ON_COMPONENT_DESTROY",
"REMOVE_STYLES_ON_COMPONENT_DESTROY_DEFAULT",
"RENDERER",
"REPLAYED_EVENTS_KEY",
"REQ_URL",
"RESPONSE_TYPE",
"RendererFactory2",
Expand Down Expand Up @@ -350,6 +352,7 @@
"_requestIdleCallback",
"_retrieveDeferBlockDataImpl",
"_retrieveHydrationInfoImpl",
"_shouldSkipNativeListenerImpl",
"_stashEventListenerImpl",
"_wasLastNodeCreated",
"acceptNode",
Expand Down Expand Up @@ -510,6 +513,7 @@
"enableLocateOrCreateTextNodeImpl",
"enableRetrieveDeferBlockDataImpl",
"enableRetrieveHydrationInfoImpl",
"enableSkipNativeListenerImpl",
"enableStashEventListenerImpl",
"enterDI",
"enterSkipHydrationBlock",
Expand Down Expand Up @@ -756,6 +760,7 @@
"isRootView",
"isScheduler",
"isSchedulerTick",
"isSkipNativeListenerImplEnabled",
"isSsrContentsIntegrity",
"isStashEventListenerImplEnabled",
"isSubscribable",
Expand Down
34 changes: 34 additions & 0 deletions packages/platform-server/test/event_replay_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,40 @@ describe('event replay', () => {
expect(onClickSpy).toHaveBeenCalled();
});

it('should replay (load) event exactly once', async () => {
// Regression test: https://github.com/angular/angular/issues/59260
// withEventReplay() was calling the (load) handler twice — once via
// invokeRegisteredReplayListeners and again via the listener instruction.
const onLoadSpy = jasmine.createSpy('onLoad');

@Component({
selector: 'app',
template: `<img id="img" src="https://placehold.co/600x400" (load)="onLoad()" />`,
})
class AppComponent {
onLoad = onLoadSpy;
}

const hydrationFeatures = () => [withEventReplay()];
const html = await ssr(AppComponent, {hydrationFeatures});
const ssrContents = getAppContents(html);
const doc = getDocument();

prepareEnvironment(doc, ssrContents);
resetTViewsFor(AppComponent);

const img = doc.getElementById('img')!;
// Simulate the image load event firing before Angular hydrates (e.g. cached image).
// `load` does not bubble, but jsaction registers a capture listener on the document
// so it will still be stashed for replay.
img.dispatchEvent(new Event('load'));

const appRef = await hydrate(doc, AppComponent, {hydrationFeatures});
appRef.tick();

expect(onLoadSpy).toHaveBeenCalledTimes(1);
});

it('stash event listeners should not conflict when multiple apps are bootstrapped', async () => {
const onClickSpy = jasmine.createSpy();

Expand Down
Loading