whatwg/html

Define that push/replaceState don't affect to :target handling

smaug---- opened this issue ยท 15 comments

https://html.spec.whatwg.org/multipage/browsers.html#history-1

One could argue push/replaceState should affect to :target handling, but given that no browser seems to do that, we should probably just spec the current handling.

https://bugzilla.mozilla.org/show_bug.cgi?id=1246240
https://bugs.webkit.org/show_bug.cgi?id=83490
https://code.google.com/p/chromium/issues/detail?id=89165

We might also want to think of some backwards compatible change to push/replaceState which allow target handling (and also hashchange events?).

While I agree this should have worked, I also agree that since no browser has ever done it, and all major browsers are in agreement on it not working, that this change is the right course of action.

We might also want to think of some backwards compatible change to push/replaceState which allow target handling (and also hashchange events?).

That would also be ideal.

(@majido: smaug____ suggested in IRC that I ask you for feedback)

a safe breaking change?

From my understanding, the only reason to be wary of changing a behavior that every browser agrees on is the danger of breaking webpages, right? There's nothing intrinsically bad about changing behavior, the badness is that webpages that used to work may no longer work, which is intrinsically bad.

Frankly, it seem far-fetched to me that fixing this would break a webpage. There are only two ways that could happen, that I can think of:

  1. a :target style that affects layout that a webapp expects to be applied on initial page load but not when the app does .pushState()/.replaceState()
  2. javascript on the page reading a computed style from something that could be selected by a :target selector and making a decision based on the computed style, that is correct on initial page load but not when the app does .pushState()/.replaceState()

There are corpora available of stylesheets used on popular websites, right? We could analyze them to determine whether 1. is true, right?

I doubt it's feasible to automatically determine whether 2. happens, but it also seems even less likely than 1., so supposing that 1. turns out in fact to never happen, could we make this change in Nightlies of each browser, and then maybe Betas, and see if anyone complains?

I'd bet no one does, and the Web would simply be a better place.

backwards-compatibly fixing this

Okay so if you wanna be a real hard-ass about this (or I'm wrong and we find a counterexample), how do authors ask browsers to recompute :target styles without adding to history and/or causing the page to jump?

What if we made the fix a global, page-wide opt-in to minimize pain for web authors, like:

history.recomputeTargetSelectors = true;

Update: Looks like there's precedence for this kind of thing: history.scrollRestoration = 'manual', which has since been integrated into the HTML Living Standard and implemented in both Chrome and Firefox.

Other options include:

  • a 4th argument to .pushState()/.replaceState() (on every call? Blech)
  • updating the CSS selector:
h3:target-including-if-changed-by-pushState-this-should-probs-have-a-better-name {
  background: yellow;
}

Once upon a time we might've linked this to the next DOCTYPE version, but that's out of the question, right?

I agree with @laughinghan and vote for fixing it early than carrying the weight of the bugs until the end of the web.

Inspired by a discussion with smaug____ in IRC, I've found a good reason to believe that virtually no webpages would be broken by the "breaking" change of updating :target selectors upon pushState: though they are currently not updated, if you however hit Back and then Fwd again, then :target rules do get applied (http://output.jsbin.com/quludi).

That means that the only way for a webapp to be broken by :target selectors being updated upon pushState, yet function correctly when you hit Back then Fwd, would be if the webapp's popstate/hashchange handlers had some kind of hysteresis that obscured the broken :target CSS. This seems highly unlikely to me. I have difficulty imagining why a popstate/hashchange handler would make a CSS change that depended on the sequence of Backs and Fwds to get to that history state, not to mention how it could obscure a :target rule that would've been broken if it had been applied upon the first .pushState().

+1 for fixing this properly - it's very surprising behaviour.

Conveniently @laughinghan's back/fwd find does provide a nice workaround for the current behaviour, which might be of interest to everybody else trying to actually use :target with browser history, and ending up here. You can double-add to the history with pushState and go back immediately to get expected behaviour:

function pushHashAndFixTargetSelector(hash) {
  history.pushState({}, "", hash);

  // Do it again and go back immediately, to force :target to update.
  history.pushState({}, "", hash);
  history.back();
}

This gives the expected result. Only side-effect (I think) is that the user's forward button is no longer disabled, although if they hit it, it won't break anything - it's the exact same URL. (Yes, this is a little nasty - we should fix it in the spec properly).

More discussion on actually using this: https://medium.com/@pimterry/better-css-only-tabs-with-target-7886c88deb75.

@pimterry Ha, thanks! Great workaround. Would be nice if pushState updated :target by default :(

FYI @pimterry @robinbastien I believe you can avoid the odd side effect of the forward button being disabled by instead doing:

function pushHashAndFixTargetSelector(hash) {
  history.pushState({}, "", hash);

  // go back and then come forward again immediately, to force :target to update.
  history.back();
  var onpopstate = window.onpopstate;
  window.onpopstate = function() {
    history.forward();
    window.onpopstate = onpopstate;
  };
}

That's what I did in my original test http://output.jsbin.com/quludi

asgh commented

When fixing this, please also consider what happens if you use pushState, followed by window.location.hash = 'foo'.

In Firefox this will update the :target, but in Webkit browsers the visible hash changes, but the :target is not affected until there is any kind of non-pushed change to the url.

The CSS Selectors Level 4 spec now says:

Note: The current URL of a page can change as a result of user actions such as activating a link targetting a different fragment within the same page; or by use of the pushState API; as well as by the more obvious actions of navigating to a different page or following a redirect (which could be initiated by protocols such as HTTP, markup instructions such as , or scripting instructions ). UAs must ensure that :local-link, as well as the :target and :target-within pseudo-classes below, respond correctly to all such changes in state.

This seems to me like it is saying that :target should be updated when pushState is called, i.e. the opposite of what the top issue in this thread proposed.

Yeah, the CSS Selectors spec is just wrong; it's unclear why they say that. They should fix it.

The current HTML spec (which governs how :target behaves, not the CSS spec) already says that :target should not change in reaction to pushState/replaceState, since they don't update the target element. So this issue can be closed.

If you'd like a new pseudo-class that matches the current fragment, and doesn't match the target element (which is only updated in specific conditions), I opened w3c/csswg-drafts#6942 where developers can advocate for that.

Ah, disappointing. As currently specced, :target is more or less useless for single-page apps. Since navigating with pushState doesn't update the target, in-app navigations don't correctly highlight targeted elements, requiring apps to implement that functionality in JavaScript. The workarounds described in this issue all have the side-effect that they destroy the browser history in one way or another.

I'm not really sure why it's valuable to not update :target on pushState(), other than that it happens to be what browsers did. As mentioned in this thread, it seems unlikely to cause significant breakage if this behavior were changed.

How come that history.back() respects :target, while history.pushState() and history.replaceState() don't?

Btw regarding the workaround, instead of this chunk:

  var onpopstate = window.onpopstate;
  window.onpopstate = function() {
    history.forward();
    window.onpopstate = onpopstate;
  };

You can use addEventListener with "once" option:

  window.addEventListener('popstate', () => { history.forward() }, { once: true } )

Which makes the same but without this onpopstate replacement magic trick. I still don't like this workaround though, I would prefer that history.pushState() and history.replaceState() would respect the :target, by changing it. But I, instead, have to tell the browser to go to my page first, then to go history.back() and history.forward(). Unfortunate...

Unfortunately the workaround isn't viable, since it triggers scroll to an anchored element upon navigation in Safari (works fine in Chrome, though). The whole reason of using history.pushState() instead of just assigning to the location.hash was to avoid page scrolling.

Are there any methods to update the :target elements, but not scroll to them that work across browsers?