-
-
Notifications
You must be signed in to change notification settings - Fork 29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Allow clicks to bubble and respect defaultPrevented #32
base: master
Are you sure you want to change the base?
Conversation
@thomas-darling This makes good sense. Can we think of any scenarios where this would break existing code? |
Sorry for the delay - we've actually had this fix in production for more than a year already, with no issues. Basically, the current behavior when clicking a link is this:
After this change, the behavior would be:
Now, the problem is, that unless you explicitly return I'm not sure how many projects would be impacted by this, as anyone relying on the current behavior would have their code execute in a somewhat strange order - but who knows what people out there may have come up with. Therefore, I guess this does technically qualify as a breaking change :-( A reasonable question here might be why Possible ways forward: Option 1. As it stands now, people would need to add a Assuming people don't do anything too crazy or custom though, it should be relatively easy to find any potential issues, by searching all views in an application using a regex such as: Option 2. Alternatively, we could make this work, without breaking things, by having We then run into the issue that a handler that does not return a value might still explicitly call Not sure how I feel about this, but it's the only option I can think of right now, that won't break things. Thoughts? |
After thinking more about this, my personal opinion is that we should go ahead an merge this, and then warn people that this is a potentially breaking change. It should be easy enough to fix by adding Basically, if you have I'd argue that anyone who relies on the current behavior, where the click handlers execute after the app has already navigated, would be doing state management in a very strange way... |
@davismj Can you look into this? It's been sitting here a while... |
@thomas-darling What is the use case for this change? Why would I click on a link and want it to do anything other than navigate to the link? |
The primary use case we have encountered has to do with tracking clicks. Another use case would be a link which needs an Makes sense? 🙂 |
Yes, thank you. To be sure, have you tried |
Yeah, the problem is, the Aurelia link handling uses capture too, and it attaches at the document level, before my code gets a chance to run - and is therefore always executed first. |
Thanks for the additional information! That gives context for this PR. |
@thomas-darling we know that because // main.js
export async function configure(aurelia) {
// ... standard stuff
await aurelia.start();
// --------------------------------------------------
// assume we all use this default implementation
const linkHandler = aurelia.container.get(History).linkHandler;
linkHandler.deactivate();
// then we do:
document.addEventListener('click', (e) => {
const trackedClickEvent = new CustomEvent('tracked-click', { bubbles: true });
document.dispatchEvent(trackedClickEvent);
// This is for better control, some track click may stop navigation, do stuff and then
// resume navigation, so we use this flag to control it
if (trackedClickEvent.defaultPrevented) {
e.stopPropagation();
// This is to dismiss any listener attached after this
e.stopImmediatePropagation();
}
});
// reactivate it
linkHandler.activate();
// --------------------------------------------------
// ... set application root to start, standard stuff
} Then in template, <a tracked-click.delegate="track($event)"></a> Note: I haven't tried this yet |
@bigopon seems like a pretty difficult workaround, don't you think? |
Its pretty difficult to work around but the workaround is not difficult 😁 |
That workaround is way too complicated, for something that really should just work. Personally, I think we should just merge this PR, as the current behavior is highly unexpected and gets in the way. I can’t think of a single scenario where you’d actually want the current behavior of invoking the click handlers after navigation already completed. |
@thomas-darling For the reason |
I’m not advocating for changing that, and it’s not part of this PR - I mentioned it, but I think the current behavior is acceptable. The only thing breaking if we merge this, is that any link that has both an Given how unlikely it is that anyone relies on the current behavior - where the handler runs after navigation completes - I doubt this would cause any real-world issues. And it should be simple to fix by adding |
Yes that's true, I didn't think of this, note that it also breaks when an ancestor of the anchor declares click handler from template, but I doubt that we would see it in ... many apps. |
Same issue here, though I'm using Swiper which overrides clicks on links to prevent navigation when dragging. The above solution doesn't work for my case since Swiper's event is non-capturing. For what it's worth, I had to override the entire History implementation with my own (since for some reason I couldn't just inject a custom LinkHandler on the built-in BrowserHistory). Here's the code I used: history-browser.tsimport {History} from 'aurelia-history';
import {LinkHandler} from 'aurelia-history-browser';
import {DOM, PLATFORM} from 'aurelia-pal';
import {DefaultPreventableLinkHandler} from './link-handler';
/**
* Overrides the default BrowserHistory implementation from aurelia-history-browser.
* See: https://github.com/aurelia/history-browser/pull/32
*/
export function configure(config): void {
config.singleton(History, DefaultPreventableBrowserHistory);
config.transient(LinkHandler, DefaultPreventableLinkHandler);
}
/**
* An implementation of the basic history API.
*/
export class DefaultPreventableBrowserHistory extends History {
static inject = [LinkHandler];
_isActive;
_checkUrlCallback;
_wantsHashChange;
_hasPushState;
location;
history;
linkHandler;
options;
root;
fragment;
/**
* Creates an instance of BrowserHistory
* @param linkHandler An instance of LinkHandler.
*/
constructor(linkHandler: LinkHandler) {
super();
this._isActive = false;
this._checkUrlCallback = this._checkUrl.bind(this);
this.location = PLATFORM.location;
this.history = PLATFORM.history;
this.linkHandler = linkHandler;
}
/**
* Activates the history object.
* @param options The set of options to activate history with.
* @returns Whether or not activation occurred.
*/
activate(options: any = {}): boolean {
if (this._isActive) {
throw new Error('History has already been activated.');
}
const wantsPushState = !!options.pushState;
this._isActive = true;
this.options = Object.assign({}, { root: '/' }, this.options, options);
// Normalize root to always include a leading and trailing slash.
this.root = ('/' + this.options.root + '/').replace(rootStripper, '/');
this._wantsHashChange = this.options.hashChange !== false;
this._hasPushState = !!(this.options.pushState && this.history && this.history.pushState);
// Determine how we check the URL state.
let eventName;
if (this._hasPushState) {
eventName = 'popstate';
} else if (this._wantsHashChange) {
eventName = 'hashchange';
}
PLATFORM.addEventListener(eventName, this._checkUrlCallback);
// Determine if we need to change the base url, for a pushState link
// opened by a non-pushState browser.
if (this._wantsHashChange && wantsPushState) {
// Transition from hashChange to pushState or vice versa if both are requested.
const loc = this.location;
const atRoot = loc.pathname.replace(/[^\/]$/, '$&/') === this.root;
// If we've started off with a route from a `pushState`-enabled
// browser, but we're currently in a browser that doesn't support it...
if (!this._hasPushState && !atRoot) {
this.fragment = this._getFragment(null, true);
this.location.replace(this.root + this.location.search + '#' + this.fragment);
// Return immediately as browser will do redirect to new url
return true;
// Or if we've started out with a hash-based route, but we're currently
// in a browser where it could be `pushState`-based instead...
} else if (this._hasPushState && atRoot && loc.hash) {
this.fragment = this._getHash().replace(routeStripper, '');
this.history.replaceState({}, DOM.title, this.root + this.fragment + loc.search);
}
}
if (!this.fragment) {
this.fragment = this._getFragment();
}
this.linkHandler.activate(this);
if (!this.options.silent) {
return this._loadUrl();
}
}
/**
* Deactivates the history object.
*/
deactivate(): void {
PLATFORM.removeEventListener('popstate', this._checkUrlCallback);
PLATFORM.removeEventListener('hashchange', this._checkUrlCallback);
this._isActive = false;
this.linkHandler.deactivate();
}
/**
* Returns the fully-qualified root of the current history object.
* @returns The absolute root of the application.
*/
getAbsoluteRoot(): string {
const origin = createOrigin(this.location.protocol, this.location.hostname, this.location.port);
return `${origin}${this.root}`;
}
/**
* Causes a history navigation to occur.
*
* @param fragment The history fragment to navigate to.
* @param options The set of options that specify how the navigation should occur.
* @return Promise if triggering navigation, otherwise true/false indicating if navigation occurred.
*/
navigate(fragment?: string, {trigger = true, replace = false} = {}): boolean {
if (fragment && absoluteUrl.test(fragment)) {
this.location.href = fragment;
return true;
}
if (!this._isActive) {
return false;
}
fragment = this._getFragment(fragment || '');
if (this.fragment === fragment && !replace) {
return false;
}
this.fragment = fragment;
let url = this.root + fragment;
// Don't include a trailing slash on the root.
if (fragment === '' && url !== '/') {
url = url.slice(0, -1);
}
// If pushState is available, we use it to set the fragment as a real URL.
if (this._hasPushState) {
url = url.replace('//', '/');
this.history[replace ? 'replaceState' : 'pushState']({}, DOM.title, url);
} else if (this._wantsHashChange) {
// If hash changes haven't been explicitly disabled, update the hash
// fragment to store history.
updateHash(this.location, fragment, replace);
} else {
// If you've told us that you explicitly don't want fallback hashchange-
// based history, then `navigate` becomes a page refresh.
this.location.assign(url);
}
if (trigger) {
return this._loadUrl(fragment);
}
return true;
}
/**
* Causes the history state to navigate back.
*/
navigateBack(): void {
this.history.back();
}
/**
* Sets the document title.
*/
setTitle(title: string): void {
DOM.title = title;
}
/**
* Sets a key in the history page state.
* @param key The key for the value.
* @param value The value to set.
*/
setState(key: string, value: any): void {
const state = Object.assign({}, this.history.state);
const { pathname, search, hash } = this.location;
state[key] = value;
this.history.replaceState(state, null, `${pathname}${search}${hash}`);
}
/**
* Gets a key in the history page state.
* @param key The key for the value.
* @return The value for the key.
*/
getState(key: string): any {
const state = Object.assign({}, this.history.state);
return state[key];
}
/**
* Returns the current index in the navigation history.
* @returns The current index.
*/
getHistoryIndex(): number {
let historyIndex = this.getState('HistoryIndex');
if (historyIndex === undefined) {
historyIndex = this.history.length - 1;
this.setState('HistoryIndex', historyIndex);
}
return historyIndex;
}
/**
* Move to a specific position in the navigation history.
* @param movement The amount of steps, positive or negative, to move.
*/
go(movement: number): void {
this.history.go(movement);
}
_getHash(): string {
return this.location.hash.substr(1);
}
_getFragment(fragment?: string, forcePushState?: boolean): string {
let root;
if (!fragment) {
if (this._hasPushState || !this._wantsHashChange || forcePushState) {
fragment = this.location.pathname + this.location.search;
root = this.root.replace(trailingSlash, '');
if (!fragment.indexOf(root)) {
fragment = fragment.substr(root.length);
}
} else {
fragment = this._getHash();
}
}
return '/' + fragment.replace(routeStripper, '');
}
_checkUrl() {
const current = this._getFragment();
if (current !== this.fragment) {
this._loadUrl();
}
}
_loadUrl(fragmentOverride?: string): boolean {
const fragment = this.fragment = this._getFragment(fragmentOverride);
return this.options.routeHandler ?
this.options.routeHandler(fragment) :
false;
}
}
// Cached regex for stripping a leading hash/slash and trailing space.
const routeStripper = /^#?\/*|\s+$/g;
// Cached regex for stripping leading and trailing slashes.
const rootStripper = /^\/+|\/+$/g;
// Cached regex for removing a trailing slash.
const trailingSlash = /\/$/;
// Cached regex for detecting if a URL is absolute,
// i.e., starts with a scheme or is scheme-relative.
// See http://www.ietf.org/rfc/rfc2396.txt section 3.1 for valid scheme format
const absoluteUrl = /^([a-z][a-z0-9+\-.]*:)?\/\//i;
// Update the hash location, either replacing the current entry, or adding
// a new one to the browser history.
function updateHash(location, fragment, replace) {
if (replace) {
const href = location.href.replace(/(javascript:|#).*$/, '');
location.replace(href + '#' + fragment);
} else {
// Some browsers require that `hash` contains a leading #.
location.hash = '#' + fragment;
}
}
function createOrigin(protocol: string, hostname: string, port: string) {
return `${protocol}//${hostname}${port ? ':' + port : ''}`;
} link-handler.tsimport { AnchorEventInfo, LinkHandler } from 'aurelia-history-browser';
import { DOM, PLATFORM } from 'aurelia-pal';
/**
* Overrides the default LinkHandler implementation to prevent eager navigation.
* See: https://github.com/aurelia/history-browser/pull/32
*/
export class DefaultPreventableLinkHandler extends LinkHandler {
/**
* Gets the href and a "should handle" recommendation, given an Event.
*
* @param event The Event to inspect for target anchor and href.
*/
static getEventInfo(event): AnchorEventInfo {
const info = {
shouldHandleEvent: false,
href: null,
anchor: null
};
if (event.defaultPrevented) {
return info;
}
const target = DefaultPreventableLinkHandler.findClosestAnchor(event.target);
if (!target || !DefaultPreventableLinkHandler.targetIsThisWindow(target)) {
return info;
}
if (target.hasAttribute('download') || target.hasAttribute('router-ignore') || target.hasAttribute('data-router-ignore')) {
return info;
}
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
return info;
}
const href = target.getAttribute('href');
info.anchor = target;
info.href = href;
const leftButtonClicked = event.which === 1;
const isRelative = href && !(href.charAt(0) === '#' || (/^[a-z]+:/i).test(href));
info.shouldHandleEvent = leftButtonClicked && isRelative;
return info;
}
/**
* Finds the closest ancestor that's an anchor element.
*
* @param el The element to search upward from.
* @returns The link element that is the closest ancestor.
*/
static findClosestAnchor(el: Element): Element {
while (el) {
if (el.tagName === 'A') {
return el;
}
el = el.parentNode as Element;
}
}
/**
* Gets a value indicating whether or not an anchor targets the current window.
*
* @param target The anchor element whose target should be inspected.
* @returns True if the target of the link element is this window; false otherwise.
*/
static targetIsThisWindow(target: Element): boolean {
const targetWindow = target.getAttribute('target');
const win = PLATFORM.global;
return !targetWindow ||
targetWindow === win.name ||
targetWindow === '_self';
}
handler;
history;
/**
* Creates an instance of DefaultLinkHandler.
*/
constructor() {
super();
this.handler = (e) => {
const {shouldHandleEvent, href} = DefaultPreventableLinkHandler.getEventInfo(e);
if (shouldHandleEvent) {
e.preventDefault();
this.history.navigate(href);
}
};
}
/**
* Activate the instance.
*
* @param history The BrowserHistory instance that navigations should be dispatched to.
*/
activate(history): void {
if (history._hasPushState) {
this.history = history;
DOM.addEventListener('click', this.handler, false);
}
}
/**
* Deactivate the instance. Event handlers and other resources should be cleaned up here.
*/
deactivate(): void {
DOM.removeEventListener('click', this.handler, false);
}
} I use the above as my history implementation from export async function configure(aurelia: Aurelia) {
aurelia.use
.basicConfiguration()
.router()
.plugin(PLATFORM.moduleName('history-browser'));
} It took way too long for me to get this working right, so it would be great if this PR would be merged! As a side note, I have tried creating a repro environment on stack blitz, but the latest |
Yep, I'd really like to see this one merged too - the current behavior must be super confusing for new users, and hacking around it is indeed tricky, at least until you figure out what's going on. Here's the hack I include in literally every app I build: // tslint:disable: no-invalid-this no-unbound-method
import { DOM } from "aurelia-framework";
import { DefaultLinkHandler, AnchorEventInfo } from "aurelia-history-browser";
// TODO: Remove this when https://github.com/aurelia/history-browser/pull/32 is released.
/**
* HACK: The `DefaultLinkHandler` attaches a click listener to the document element, which
* ensures a click on any anchor element activates the router. Unfortunately, it listens
* for this event in the `capture` phase, which means no other handlers will ever get a
* chance to handle the event.
* Therefore, we override the `activate` method to change the event listener to listen
* for the event in the `bubble` phase instead.
*/
DefaultLinkHandler.prototype.activate = function(history: any): void
{
if (history._hasPushState)
{
(this as any).history = history;
DOM.addEventListener("click", (this as any).handler, false);
}
};
/**
* HACK: The `DefaultLinkHandler` does not consider whether `preventDefault()` has been
* called on the event, which means it is impossible to prevent the route change.
* Therefore, we override the static `getEventInfo` method to change the behavior,
* such that events are not handled by the router if default has been prevented.
*/
const getEventInfoFunc = DefaultLinkHandler.getEventInfo;
DefaultLinkHandler.getEventInfo = function(event: Event): AnchorEventInfo
{
if (event.defaultPrevented)
{
return { shouldHandleEvent: false, href: null, anchor: null } as any;
}
// Continue with the original method.
return getEventInfoFunc.apply(this, arguments as any);
}; On a side note, @jemhuntr, what do you mean by this?
I just had a look in the code, and the bugs are stil there, so... :-) |
That looks sooo much cleaner than having to replace the entire BrowserHistory's implementation :)))
I spent some time trying to create a repro at https://codesandbox.io/s/wox4mzyv5l (CodeSandbox, not StackBlitz, sorry). I couldn't reproduce the issue since Swiper is able to override the clicks just fine. EDIT: Apparently CodeSandbox includes a link handler at |
Haha, yeah, though you might like it a little better :-) The way I usually manage hacks like this, is to put each in its own file, and then import them into Oh, and it makes sense that this works in your CodeSandbox - there you are loading the Swiper script before the Aurelia script, which means it gets to attach its own handler first ;-) |
Could we put the new behavior behind a setting? We could use that to maintain backward compatibility while also enabling people who need this to get it working easily. We could then port this forward to vNext so we don't have this issue in the future and it becomes the standard behavior. Thoughts? @thomas-darling What do you think about putting this behind a history config setting? |
@EisenbergEffect We can definitely do that for vCurrent, to preserve backwards compatibility :-) I don't think we should have that setting in vNext though - there it should just behave correctly. Regarding implementation:
I can update the pull request with the changes sometime during this week :-) |
The name |
The
DefaultLinkHandler
attaches a click listener to the document element, which ensures a click on any anchor element activates the router.Unfortunately, it listens for this event in the
capture
phase, which means no other handlers will ever get a chance to handle the event.This makes it impossible to intercept the click and execute code before the navigation occurs, e.g. to cancel the navigation or track the click.
The
DefaultLinkHandler
also does not consider whetherpreventDefault()
has been called on the event. This makes it impossible to cancel the navigation.To resolve this, and to be consistent with how links natively work, this pull request:
DefaultLinkHandler
to listen for clicks inbubble
phase instead ofcapture
phase.DefaultLinkHandler
to not navigate ifpreventDefault()
was called on the click event.