Skip to content

Commit

Permalink
Merge pull request #122 from swup/svg-links
Browse files Browse the repository at this point in the history
Respect swup link selector for preloaded links
  • Loading branch information
daun authored Jan 29, 2024
2 parents ebf9010 + 54e0f8c commit d244c33
Show file tree
Hide file tree
Showing 10 changed files with 863 additions and 611 deletions.
1,249 changes: 676 additions & 573 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,13 @@
"@swup/plugin": "^4.0.0"
},
"devDependencies": {
"@playwright/test": "^1.39.0",
"@playwright/test": "^1.41.1",
"@swup/cli": "^5.0.1",
"@types/jsdom": "^21.1.4",
"jsdom": "^22.1.0",
"jsdom": "^24.0.0",
"network-information-types": "^0.1.1",
"serve": "^14.2.1",
"vitest": "^0.34.6"
"vitest": "^1.2.2"
},
"peerDependencies": {
"swup": "^4.0.0"
Expand Down
45 changes: 27 additions & 18 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
PageData,
HookDefaultHandler
} from 'swup';
import { deviceSupportsHover, networkSupportsPreloading, whenIdle } from './util.js';
import { deviceSupportsHover, networkSupportsPreloading, whenIdle, isAnchorElement } from './util.js';
import createQueue, { Queue } from './queue.js';
import createObserver, { Observer } from './observer.js';

Expand All @@ -25,14 +25,16 @@ declare module 'swup' {
preloadLinks?: () => void;
}
export interface HookDefinitions {
'link:hover': { el: HTMLAnchorElement; event: DelegateEvent };
'link:hover': { el: HTMLAnchorElement | SVGAElement; event: DelegateEvent };
'page:preload': { url: string, page?: PageData };
}
export interface HookReturnValues {
'page:preload': Promise<PageData>;
}
}

export type AnchorElement = HTMLAnchorElement | SVGAElement;

type VisibleLinkPreloadOptions = {
/** Enable preloading of links entering the viewport */
enabled: boolean;
Expand All @@ -43,7 +45,7 @@ type VisibleLinkPreloadOptions = {
/** Containers to look for links in */
containers: string[];
/** Callback for opting out selected elements from preloading */
ignore: (el: HTMLAnchorElement) => boolean;
ignore: (el: AnchorElement) => boolean;
};

export type PluginOptions = {
Expand Down Expand Up @@ -194,7 +196,7 @@ export default class SwupPreloadPlugin extends Plugin {
if (!deviceSupportsHover()) return;

const el = event.delegateTarget;
if (!(el instanceof HTMLAnchorElement)) return;
if (!isAnchorElement(el)) return;

this.swup.hooks.callSync('link:hover', undefined, { el, event });
this.preload(el, { priority: true });
Expand All @@ -208,7 +210,7 @@ export default class SwupPreloadPlugin extends Plugin {
if (deviceSupportsHover()) return;

const el = event.delegateTarget;
if (!(el instanceof HTMLAnchorElement)) return;
if (!isAnchorElement(el)) return;

this.preload(el, { priority: true });
};
Expand All @@ -218,7 +220,7 @@ export default class SwupPreloadPlugin extends Plugin {
*/
protected onFocus: DelegateEventHandler = (event) => {
const el = event.delegateTarget;
if (!(el instanceof HTMLAnchorElement)) return;
if (!isAnchorElement(el)) return;

this.preload(el, { priority: true });
};
Expand All @@ -236,26 +238,26 @@ export default class SwupPreloadPlugin extends Plugin {
*/
async preload(url: string, options?: PreloadOptions): Promise<PageData | void>;
async preload(urls: string[], options?: PreloadOptions): Promise<(PageData | void)[]>;
async preload(el: HTMLAnchorElement, options?: PreloadOptions): Promise<PageData | void>;
async preload(els: HTMLAnchorElement[], options?: PreloadOptions): Promise<(PageData | void)[]>;
async preload(el: AnchorElement, options?: PreloadOptions): Promise<PageData | void>;
async preload(els: AnchorElement[], options?: PreloadOptions): Promise<(PageData | void)[]>;
async preload(
input: string | HTMLAnchorElement,
input: string | AnchorElement,
options?: PreloadOptions
): Promise<PageData | void>;
async preload(
input: string | string[] | HTMLAnchorElement | HTMLAnchorElement[],
input: string | string[] | AnchorElement | AnchorElement[],
options: PreloadOptions = {}
): Promise<PageData | (PageData | void)[] | void> {
let url: string;
let el: HTMLAnchorElement | undefined;
let el: AnchorElement | undefined;
const priority = options.priority ?? false;

// Allow passing in array of urls or elements
if (Array.isArray(input)) {
return Promise.all(input.map((link) => this.preload(link)));
}
// Allow passing in an anchor element
else if (input instanceof HTMLAnchorElement) {
else if (isAnchorElement(input)) {
el = input;
({ href: url } = Location.fromElement(input));
}
Expand All @@ -268,6 +270,9 @@ export default class SwupPreloadPlugin extends Plugin {
return;
}

// Return if no url passed in
if (!url) return;

// Already preloading? Return existing promise
if (this.preloadPromises.has(url)) {
return this.preloadPromises.get(url);
Expand Down Expand Up @@ -307,7 +312,7 @@ export default class SwupPreloadPlugin extends Plugin {
preloadLinks(): void {
whenIdle(() => {
const selector = 'a[data-swup-preload], [data-swup-preload-all] a';
const links = Array.from(document.querySelectorAll<HTMLAnchorElement>(selector));
const links = Array.from(document.querySelectorAll<AnchorElement>(selector));
links.forEach((el) => this.preload(el));
});
}
Expand Down Expand Up @@ -350,13 +355,17 @@ export default class SwupPreloadPlugin extends Plugin {
}

const { threshold, delay, containers } = this.options.preloadVisibleLinks;
const callback = (el: HTMLAnchorElement) => this.preload(el);
const filter = (el: HTMLAnchorElement) => {
const callback = (el: AnchorElement) => this.preload(el);
const filter = (el: AnchorElement) => {
/** First, run the custom callback */
if (this.options.preloadVisibleLinks.ignore(el)) return false;
/** Second, run all default checks */
return this.shouldPreload(el.href, { el });
/** Second, check if it's a valid swup link */
if (!el.matches(this.swup.options.linkSelector)) return false;
/** Third, run all default checks */
const { href } = Location.fromElement(el);
return this.shouldPreload(href, { el });
};

this.preloadObserver = createObserver({ threshold, delay, containers, callback, filter });
this.preloadObserver.start();
}
Expand All @@ -373,7 +382,7 @@ export default class SwupPreloadPlugin extends Plugin {
/**
* Check whether a URL and/or element should trigger a preload.
*/
protected shouldPreload(location: string, { el }: { el?: HTMLAnchorElement } = {}): boolean {
protected shouldPreload(location: string, { el }: { el?: AnchorElement } = {}): boolean {
const { url, href } = Location.fromUrl(location);

// Network too slow?
Expand Down
31 changes: 19 additions & 12 deletions src/observer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { Location } from 'swup';

import { AnchorElement } from './index.js';
import { whenIdle } from './util.js';

export type Observer = {
Expand All @@ -16,33 +19,34 @@ export default function createObserver({
threshold: number;
delay: number;
containers: string[];
callback: (el: HTMLAnchorElement) => void;
filter: (el: HTMLAnchorElement) => boolean;
callback: (el: AnchorElement) => void;
filter: (el: AnchorElement) => boolean;
}): Observer {
const visibleLinks = new Map<string, Set<HTMLAnchorElement>>();
const visibleLinks = new Map<string, Set<AnchorElement>>();

// Create an observer to add/remove links when they enter the viewport
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
add(entry.target as HTMLAnchorElement);
add(entry.target as AnchorElement);
} else {
remove(entry.target as HTMLAnchorElement);
remove(entry.target as AnchorElement);
}
});
},
{ threshold }
);

// Preload link if it is still visible after a configurable timeout
const add = (el: HTMLAnchorElement) => {
const elements = visibleLinks.get(el.href) ?? new Set();
visibleLinks.set(el.href, elements);
const add = (el: AnchorElement) => {
const { href } = Location.fromElement(el);
const elements = visibleLinks.get(href) ?? new Set();
visibleLinks.set(href, elements);
elements.add(el);

setTimeout(() => {
const elements = visibleLinks.get(el.href);
const elements = visibleLinks.get(href);
if (elements?.size) {
callback(el);
observer.unobserve(el);
Expand All @@ -52,16 +56,19 @@ export default function createObserver({
};

// Remove link from list of visible links
const remove = (el: HTMLAnchorElement) => visibleLinks.get(el.href)?.delete(el);
const remove = (el: AnchorElement) => {
const { href } = Location.fromElement(el);
visibleLinks.get(href)?.delete(el);
};

// Clear list of visible links
const clear = () => visibleLinks.clear();

// Scan DOM for preloadable links and start observing their visibility
const observe = () => {
whenIdle(() => {
const selector = containers.map((root) => `${root} a[href]`).join(', ');
const links = Array.from(document.querySelectorAll<HTMLAnchorElement>(selector));
const selector = containers.map((root) => `${root} a[*|href]`).join(', ');
const links = Array.from(document.querySelectorAll<AnchorElement>(selector));
links.filter((el) => filter(el)).forEach((el) => observer.observe(el));
});
};
Expand Down
7 changes: 7 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ export function deviceSupportsHover() {
return window.matchMedia('(hover: hover)').matches;
}

/**
* Is this element an anchor element?
*/
export function isAnchorElement(element: unknown): element is HTMLAnchorElement | SVGAElement {
return !!element && (element instanceof HTMLAnchorElement || element instanceof SVGAElement);
}

/**
* Safe requestIdleCallback function that falls back to setTimeout
*/
Expand Down
32 changes: 32 additions & 0 deletions tests/fixtures/link-selector-default.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Link types</title>
<link rel="stylesheet" href="/assets/style.css" />
</head>
<body>
<main id="swup" class="transition-main">
<h1>Link types</h1>

<p>Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.</p>
<ul>
<li><a href="/page-1.html">Page 1</a></li>
</ul>

<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<a xlink:href="/page-2.html">
<text x="10" y="10" font-size="2">SVG link</text>
</a>
</svg>
</main>
<script src="/dist/swup.umd.js"></script>
<script src="/dist/index.umd.js"></script>
<script>
window._swup = new Swup({
plugins: [new SwupPreloadPlugin()]
});
</script>
</body>
</html>
33 changes: 33 additions & 0 deletions tests/fixtures/link-selector-modified.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Link types</title>
<link rel="stylesheet" href="/assets/style.css" />
</head>
<body>
<main id="swup" class="transition-main">
<h1>Link types</h1>

<p>Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.</p>
<ul>
<li><a href="/page-1.html">Page 1</a></li>
</ul>

<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<a xlink:href="/page-2.html">
<text x="10" y="10" font-size="2">SVG link</text>
</a>
</svg>
</main>
<script src="/dist/swup.umd.js"></script>
<script src="/dist/index.umd.js"></script>
<script>
window._swup = new Swup({
linkSelector: 'a[href], a[*|href]',
plugins: [new SwupPreloadPlugin()]
});
</script>
</body>
</html>
39 changes: 39 additions & 0 deletions tests/fixtures/visible-links-selector.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Preload visible links with options</title>
<link rel="stylesheet" href="/assets/style.css" />
</head>
<body>
<main id="swup" class="transition-main">
<h1>Preload visible links with modified selector</h1>
<p>Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.</p>
<ul>
<li><a href="/page-1.html">Page 1</a></li>
<li><a href="/page-2.html">Page 2</a></li>
<li><a href="/page-3.html">Page 3</a></li>
<li><a href="/page-4.html">Page 4</a></li>
<li>
<svg viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg">
<a xlink:href="/page-5.html"><text x="10" y="10" font-size="2">Page 5</text></a>
</svg>
</li>
</ul>
</main>
<script src="/dist/swup.umd.js"></script>
<script src="/dist/index.umd.js"></script>
<script>
window._swup = new Swup({
linkSelector: 'li:nth-child(odd) a, svg a',
plugins: [new SwupPreloadPlugin({
preloadVisibleLinks: {
enabled: true,
delay: 10
}
})]
});
</script>
</body>
</html>
9 changes: 4 additions & 5 deletions tests/functional/inc/commands.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { expect, Page } from '@playwright/test';
import type Swup from 'swup';
import type SwupPreloadPlugin from '../../../src/index';

declare global {
interface Window {
Expand Down Expand Up @@ -61,19 +60,19 @@ export async function navigateWithSwup(
export async function expectToBeAt(page: Page, url: string, title?: string) {
await expect(page).toHaveURL(url);
if (title) {
await expect(page).toHaveTitle(title);
await expect(page.locator('h1')).toContainText(title);
await expect(page, `Expected title: ${title}`).toHaveTitle(title);
await expect(page.locator('h1'), `Expected h1: ${title}`).toContainText(title);
}
}

export async function expectSwupToHaveCacheEntry(page: Page, url: string) {
const exists = () => page.evaluate((url) => window._swup.cache.has(url), url);
await expect(async () => expect(await exists()).toBe(true)).toPass();
await expect(async () => expect(await exists(), `Expected ${url} to be in cache`).toBe(true)).toPass();
}

export async function expectSwupNotToHaveCacheEntry(page: Page, url: string) {
const exists = () => page.evaluate((url) => window._swup.cache.has(url), url);
expect(await exists()).toBe(false);
expect(await exists(), `Expected ${url} not to be in cache`).toBe(false);
}

export async function expectSwupToHaveCacheEntries(page: Page, urls: string[]) {
Expand Down
Loading

0 comments on commit d244c33

Please sign in to comment.