Repository navigation
Description
Which @angular/* package(s) are the source of the bug?
Don't known / other
Is this a regression?
Yes
Description
Angular issue: an early click makes a prerendered target="_blank" link permanently unclickable (stale __jsaction cache)
Package: @angular/core (event replay / incremental hydration)
Version: 22.2.3 (reproduced on a fresh ng new --ssr project; first seen after upgrading an app from 19 to 22)
Browser: Chrome (stable)
Description
With the v22 default provideClientHydration() (incremental hydration, which brings event replay), a prerendered
<a routerLink="..." target="_blank"> that is clicked while event replay is still active becomes permanently
unclickable. Every later click is preventDefault()ed by the event contract, even after Angular has removed the
jsaction attribute. No new tab opens until the page is reloaded.
Clicking only after the app is stable works. Using withNoIncrementalHydration(), withEventReplay() also works.
Minimal reproduction
ng new repro --ssr --routing --defaults (Angular 22.2.3), then replace three files:
src/app/app.ts
import { Component, PendingTasks, inject } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterLink, RouterOutlet],
template: `
<a id="new-tab" routerLink="/other" target="_blank">Open /other in a new tab</a>
<router-outlet />
`,
})
export class App {
constructor() {
// Stand-in for an HTTP request in flight after load: keeps the app unstable for 3 s.
inject(PendingTasks).run(() => new Promise<void>((resolve) => setTimeout(resolve, 3000)));
}
}src/app/other.ts
import { Component } from '@angular/core';
@Component({ selector: 'app-other', template: `<p>Other page</p>` })
export class Other {}src/app/app.routes.ts
import { Component } from '@angular/core';
import { Routes } from '@angular/router';
import { Other } from './other';
@Component({ selector: 'app-home', template: `<p>Home page</p>` })
class Home {}
export const routes: Routes = [
{ path: '', component: Home },
{ path: 'other', component: Other },
];app.config.ts stays as generated: provideClientHydration().
Build (ng build, prerender), serve dist/repro/browser statically, open /.
Steps
- Within the first ~3 s (app bootstrapped, not yet stable), click "Open /other in a new tab".
- Wait until the app is stable; the
jsactionattribute disappears from the link. - Click the link again.
Expected: step 3 opens /other in a new tab.
Actual: nothing happens. The click is preventDefault()ed by the event contract; this repeats on every click until reload.
Control cases (same app, real trusted clicks):
| Configuration | Early click, then click at 6 s | Clicks only at 6 s and 8 s |
|---|---|---|
provideClientHydration() (v22 default) |
6 s click: no tab (defaultPrevented=true) |
both open a tab |
provideClientHydration(withNoIncrementalHydration(), withEventReplay()) |
both open a tab | both open a tab |
Stack of the preventDefault() on the 6 s click (dev build): Dispatcher.dispatch → EventDispatcher.dispatch →
_EventContract.dispatcher → _EventContract.handleEventInfo → _EventContract.handleEvent → listener on <body>.
Root cause (from the 22.2.3 sources)
- On the early click,
ActionResolver.parseActions(element)parsesjsaction="click:;"and caches the parsed map
on the element as the propertyelement.__jsaction(set(actionElement, actionMap)). - When the block/app is cleaned up,
removeListeners(el)does
el.removeAttribute('jsaction'); el.removeAttribute('ngb'); el.__jsaction_fns = undefined;
but it does not clearel.__jsaction. - With incremental hydration enabled, the
EventContractis not cleaned up afterwhenStable().withEventReplay's
bootstrap listener only callseventContract.cleanUp()on app destroy whenisIncrementalHydrationEnabled, so the
<body>listener stays active. - On every later click,
parseActionsreturns the stale cached map, so an action is found. Because the element is an
<a>and the event is a click,shouldPreventDefaultBeforeDispatchingreturns true and the default (opening the new
tab) is cancelled. RouterLink then leavestarget="_blank"to the browser, so nothing navigates.
Without the early click, __jsaction was never cached; after the attribute is removed parseActions sees no attribute,
and the click is not prevented. With incremental hydration off, the contract is cleaned up at stable, so the stale cache
is never consulted.
Same-tab routerLinks mask the problem (RouterLink navigates itself after the default is prevented); any anchor that
relies on the browser default (target="_blank", plain href with a (click) listener, download links) is affected.
Workaround we use
An inline script at the end of index.html removes jsaction from a[target="_blank"] before any click, so these
links are never intercepted or cached:
<script>
document.querySelectorAll('a[target="_blank"][jsaction]').forEach(function (a) { a.removeAttribute('jsaction'); });
</script>Please provide a link to a minimal reproduction of the bug
No response
Please provide the exception or error you saw
Please provide the environment you discovered this bug in (run ng version)
Angular CLI : 22.2.3
Angular : 22.2.3
Node.js : 24.21.0
Package Manager : npm 11.19.0
Operating System : darwin arm64
Anything else?
No response
Activity
fix(core): clear cached jsaction actions when removing listeners