Skip to content

Commit

Permalink
Merge pull request #5743 from squidfunk/fix/instant-loading
Browse files Browse the repository at this point in the history
Improve support for instant loading with keyboard navigation
  • Loading branch information
squidfunk authored Sep 24, 2023
2 parents 7e6f15b + 639dbac commit da57083
Show file tree
Hide file tree
Showing 18 changed files with 156 additions and 85 deletions.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion material/overrides/main.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,5 @@
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="{{ 'assets/javascripts/custom.4eda089e.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/custom.a4bbca43.min.js' | url }}"></script>
{% endblock %}
29 changes: 0 additions & 29 deletions material/templates/assets/javascripts/bundle.55099adf.min.js

This file was deleted.

This file was deleted.

29 changes: 29 additions & 0 deletions material/templates/assets/javascripts/bundle.726fbb30.min.js

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion material/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@
</script>
{% endblock %}
{% block scripts %}
<script src="{{ 'assets/javascripts/bundle.55099adf.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.726fbb30.min.js' | url }}"></script>
{% for script in config.extra_javascript %}
{{ script | script_tag }}
{% endfor %}
Expand Down
4 changes: 2 additions & 2 deletions material/templates/partials/footer.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<nav class="md-footer__inner md-grid" aria-label="{{ lang.t('footer') }}" {{ hidden }}>
{% if page.previous_page %}
{% set direction = lang.t("footer.previous") %}
<a href="{{ page.previous_page.url | url }}" class="md-footer__link md-footer__link--prev" aria-label="{{ direction }}: {{ page.previous_page.title | e }}" rel="prev">
<a href="{{ page.previous_page.url | url }}" class="md-footer__link md-footer__link--prev" aria-label="{{ direction }}: {{ page.previous_page.title | e }}">
<div class="md-footer__button md-icon">
{% set icon = config.theme.icon.previous or "material/arrow-left" %}
{% include ".icons/" ~ icon ~ ".svg" %}
Expand All @@ -27,7 +27,7 @@
{% endif %}
{% if page.next_page %}
{% set direction = lang.t("footer.next") %}
<a href="{{ page.next_page.url | url }}" class="md-footer__link md-footer__link--next" aria-label="{{ direction }}: {{ page.next_page.title | e }}" rel="next">
<a href="{{ page.next_page.url | url }}" class="md-footer__link md-footer__link--next" aria-label="{{ direction }}: {{ page.next_page.title | e }}">
<div class="md-footer__title">
<span class="md-footer__direction">
{{ direction }}
Expand Down
2 changes: 1 addition & 1 deletion src/templates/assets/javascripts/_/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export type Flag =
| "header.autohide" /* Hide header */
| "navigation.expand" /* Automatic expansion */
| "navigation.indexes" /* Section pages */
| "navigation.instant" /* Instant loading */
| "navigation.instant" /* Instant navigation */
| "navigation.sections" /* Section navigation */
| "navigation.tabs" /* Tabs navigation */
| "navigation.tabs.sticky" /* Tabs navigation (sticky) */
Expand Down
30 changes: 27 additions & 3 deletions src/templates/assets/javascripts/browser/location/_/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@

import { Subject } from "rxjs"

import { feature } from "~/_"
import { h } from "~/utilities"

/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
Expand All @@ -43,10 +46,31 @@ export function getLocation(): URL {
/**
* Set location
*
* @param url - URL to change to
* If instant navigation is enabled, this function creates a temporary anchor
* element, sets the `href` attribute, appends it to the body, clicks it, and
* then removes it again. The event will bubble up the DOM and trigger be
* intercepted by the instant loading business logic.
*
* Note that we must append and remove the anchor element, or the event will
* not bubble up the DOM, making it impossible to intercept it.
*
* @param url - URL to navigate to
* @param navigate - Force navigation
*/
export function setLocation(url: URL | HTMLLinkElement): void {
location.href = url.href
export function setLocation(
url: URL | HTMLLinkElement, navigate = false
): void {
if (feature("navigation.instant") && !navigate) {
const el = h("a", { href: url.href })
document.body.appendChild(el)
el.click()
el.remove()

// If we're not using instant navigation, and the page should not be reloaded
// just instruct the browser to navigate to the given URL
} else {
location.href = url.href
}
}

/* ------------------------------------------------------------------------- */
Expand Down
6 changes: 3 additions & 3 deletions src/templates/assets/javascripts/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ import {
import {
SearchIndex,
setupClipboardJS,
setupInstantLoading,
setupInstantNavigation,
setupVersionSelector
} from "./integrations"
import {
Expand Down Expand Up @@ -143,9 +143,9 @@ const index$ = document.forms.namedItem("search")
const alert$ = new Subject<string>()
setupClipboardJS({ alert$ })

/* Set up instant loading, if enabled */
/* Set up instant navigation, if enabled */
if (feature("navigation.instant"))
setupInstantLoading({ location$, viewport$ })
setupInstantNavigation({ location$, viewport$ })
.subscribe(document$)

/* Set up version selector */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export function mountAnnounce(
if (!feature("announce.dismiss") || !el.childElementCount)
return EMPTY

/* Support instant loading - see https://t.ly/3FTme */
/* Support instant navigation - see https://t.ly/3FTme */
if (!el.hidden) {
const content = getElement(".md-typeset", el)
if (__md_hash(content.innerHTML) === __md_get("__announce"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export function watchSearchQuery(
first(active => !active)
)
.subscribe(() => {
const url = new URL(location.href)
const url = getLocation()
url.searchParams.delete("q")
history.replaceState({}, "", `${url}`)
})
Expand Down
91 changes: 70 additions & 21 deletions src/templates/assets/javascripts/integrations/instant/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,48 @@ interface SetupOptions {
viewport$: Observable<Viewport> /* Viewport observable */
}

/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */

/**
* Create a map of head elements for lookup and replacement
*
* @param head - Document head
*
* @returns Element map
*/
function lookup(head: HTMLHeadElement): Map<string, HTMLElement> {
const tags = new Map<string, HTMLElement>()
for (const el of getElements(":scope > *", head)) {
let html = el.outerHTML

// If the current element is a style sheet or script, we must resolve the
// URL relative to the current location and make it absolute, so it's easy
// to deduplicate it later on by comparing the outer HTML of tags. We must
// keep identical style sheets and scripts without replacing them.
for (const key of ["href", "src"]) {
const value = el.getAttribute(key)!
if (value === null)
continue

// Resolve URL relative to current location
const url = new URL(value, getLocation())
const ref = el.cloneNode() as HTMLElement

// Set resolved URL and retrieve HTML for deduplication
ref.setAttribute(key, `${url}`)
html = ref.outerHTML
}

// Index element in tag map
tags.set(html, el)
}

// Return tag map
return tags
}

/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
Expand All @@ -84,7 +126,7 @@ interface SetupOptions {
*
* @returns Document observable
*/
export function setupInstantLoading(
export function setupInstantNavigation(
{ location$, viewport$ }: SetupOptions
): Observable<Document> {
const config = configuration()
Expand Down Expand Up @@ -188,10 +230,10 @@ export function setupInstantLoading(
history.pushState(null, "", url)
})

// Emit URL that should be fetched via instant loading on location subject,
// which was passed into this function. The idea is that instant loading can
// be intercepted by other parts of the application, which can synchronously
// back up or restore state before instant loading happens.
// Emit URL that should be fetched via instant navigation on location subject,
// which was passed into this function. Instant navigation can be intercepted
// by other parts of the application, which can synchronously back up or
// restore state before instant navigation happens.
instant$.subscribe(location$)

// Fetch document - when fetching, we could use `responseType: document`, but
Expand All @@ -201,7 +243,7 @@ export function setupInstantLoading(
// reason, we fall back to regular navigation and set the location explicitly,
// which will force-load the page. Furthermore, we must pre-warm the buffer
// for the duplicate check, or the first click on an anchor link will also
// trigger an instant loading event, which doesn't make sense.
// trigger an instant navigation event, which doesn't make sense.
const response$ = location$
.pipe(
startWith(getLocation()),
Expand All @@ -210,30 +252,22 @@ export function setupInstantLoading(
switchMap(url => request(url)
.pipe(
catchError(() => {
setLocation(url)
setLocation(url, true)
return EMPTY
})
)
)
)

// Initialize the DOM parser, parse the returned HTML, and replace selected
// meta tags and components before handing control down to the application
// components before handing control down to the application
const dom = new DOMParser()
const document$ = response$
.pipe(
switchMap(res => res.text()),
switchMap(res => {
const document = dom.parseFromString(res, "text/html")
const next = dom.parseFromString(res, "text/html")
for (const selector of [

// Meta tags
"title",
"link[rel=canonical]",
"meta[name=author]",
"meta[name=description]",

// Components
"[data-md-component=announce]",
"[data-md-component=container]",
"[data-md-component=header-topic]",
Expand All @@ -245,7 +279,7 @@ export function setupInstantLoading(
: []
]) {
const source = getOptionalElement(selector)
const target = getOptionalElement(selector, document)
const target = getOptionalElement(selector, next)
if (
typeof source !== "undefined" &&
typeof target !== "undefined"
Expand All @@ -254,13 +288,28 @@ export function setupInstantLoading(
}
}

// After meta tags and components were replaced, re-evaluate scripts
// Update meta tags
const source = lookup(document.head)
const target = lookup(next.head)
for (const [html, el] of target) {
if (source.has(html)) {
source.delete(html)
} else {
document.head.appendChild(el)
}
}

// Remove meta tags that are not present in the new document
for (const el of source.values())
el.remove()

// After components and meta tags were replaced, re-evaluate scripts
// that were provided by the author as part of Markdown files
const container = getComponentElement("container")
return concat(getElements("script", container))
.pipe(
switchMap(el => {
const script = document.createElement("script")
const script = next.createElement("script")
if (el.src) {
for (const name of el.getAttributeNames())
script.setAttribute(name, el.getAttribute(name)!)
Expand All @@ -279,7 +328,7 @@ export function setupInstantLoading(
}
}),
ignoreElements(),
endWith(document)
endWith(next)
)
}),
share()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export type Sitemap = string[]
* Preprocess a list of URLs
*
* This function replaces the `site_url` in the sitemap with the actual base
* URL, to allow instant loading to work in occasions like Netlify previews.
* URL, to allow instant navigation to work in occasions like Netlify previews.
*
* @param urls - URLs
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ export function setupVersionSelector(
// find the same page, as we might have different deployments
// due to aliases. However, if we're outside the version
// selector, we must abort here, because we might otherwise
// interfere with instant loading. We need to refactor this
// at some point together with instant loading.
// interfere with instant navigation. We need to refactor this
// at some point together with instant navigation.
//
// See https://github.com/squidfunk/mkdocs-material/issues/4012
if (!ev.target.closest(".md-version")) {
Expand Down Expand Up @@ -143,7 +143,7 @@ export function setupVersionSelector(
)
)
)
.subscribe(url => setLocation(url))
.subscribe(url => setLocation(url, true))

/* Render version selector and warning */
combineLatest([versions$, current$])
Expand All @@ -152,7 +152,7 @@ export function setupVersionSelector(
topic.appendChild(renderVersionSelector(versions, current))
})

/* Integrate outdated version banner with instant loading */
/* Integrate outdated version banner with instant navigation */
document$.pipe(switchMap(() => current$))
.subscribe(current => {

Expand Down
2 changes: 0 additions & 2 deletions src/templates/partials/footer.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@
href="{{ page.previous_page.url | url }}"
class="md-footer__link md-footer__link--prev"
aria-label="{{ direction }}: {{ page.previous_page.title | e }}"
rel="prev"
>
<div class="md-footer__button md-icon">
{% set icon = config.theme.icon.previous or "material/arrow-left" %}
Expand All @@ -66,7 +65,6 @@
href="{{ page.next_page.url | url }}"
class="md-footer__link md-footer__link--next"
aria-label="{{ direction }}: {{ page.next_page.title | e }}"
rel="next"
>
<div class="md-footer__title">
<span class="md-footer__direction">
Expand Down

0 comments on commit da57083

Please sign in to comment.