Adds a basic @astrojs/prefetch integration

This commit is contained in:
Tony Sullivan 2022-06-25 20:27:46 -05:00
parent c3d41d1f60
commit 7ebc51fc27
15 changed files with 383 additions and 1 deletions

View file

@ -0,0 +1,3 @@
# @astrojs/prefetch 🔗
TODO

View file

@ -0,0 +1,39 @@
{
"name": "@astrojs/prefetch",
"description": "⚡️ Faster subsequent page-loads by prefetching in-viewport links during idle time ",
"version": "0.0.1",
"type": "module",
"types": "./dist/index.d.ts",
"author": "withastro",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/withastro/astro.git",
"directory": "packages/astro-prefetch"
},
"bugs": "https://github.com/withastro/astro/issues",
"homepage": "https://astro.build",
"exports": {
".": "./dist/index.js",
"./client.js": "./dist/client.js",
"./package.json": "./package.json"
},
"scripts": {
"build": "astro-scripts build \"src/**/*.ts\" && tsc",
"build:ci": "astro-scripts build \"src/**/*.ts\"",
"dev": "astro-scripts dev \"src/**/*.ts\"",
"test": "playwright test",
"test:match": "playwright test -g"
},
"devDependencies": {
"@types/chai": "^4.3.1",
"@types/chai-as-promised": "^7.1.5",
"@types/mocha": "^9.1.1",
"astro": "workspace:*",
"astro-scripts": "workspace:*",
"playwright": "^1.22.2"
},
"dependencies": {
"throttles": "^1.0.1"
}
}

View file

@ -0,0 +1,89 @@
import throttle from 'throttles';
import requestIdleCallback from './requestIdleCallback.js';
const events = ['mouseenter', 'touchstart', 'focus'];
const preloaded = new Set<string>();
function shouldPreload({ href }: { href: string }) {
try {
const url = new URL(href);
return (
window.location.origin === url.origin &&
window.location.pathname !== url.hash &&
!preloaded.has(href)
);
} catch {}
return false;
}
let parser: DOMParser;
let observer: IntersectionObserver;
function observe(link: HTMLAnchorElement) {
preloaded.add(link.href);
observer.observe(link);
events.map((event) => link.addEventListener(event, onLinkEvent, { once: true }));
}
function unobserve(link: HTMLAnchorElement) {
observer.unobserve(link);
events.map((event) => link.removeEventListener(event, onLinkEvent));
}
function onLinkEvent({ target }: Event) {
if (!(target instanceof HTMLAnchorElement)) {
return;
}
preloadHref(target);
}
async function preloadHref(link: HTMLAnchorElement) {
unobserve(link);
const { href } = link;
try {
const contents = await fetch(href).then((res) => res.text());
parser = parser || new DOMParser();
const html = parser.parseFromString(contents, 'text/html');
const styles = Array.from(html.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'));
await Promise.all(styles.map(({ href }) => fetch(href)));
} catch {}
}
export interface PrefetchOptions {
selectors?: string | HTMLAnchorElement[];
root?: ParentNode;
}
export default function prefetch({
root = document,
selectors = 'a[href][rel="prefetch"]',
}: PrefetchOptions) {
const [toAdd, isDone] = throttle();
observer =
observer ||
new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && entry.target instanceof HTMLAnchorElement) {
toAdd(() => preloadHref(entry.target as HTMLAnchorElement).finally(isDone));
}
});
});
requestIdleCallback(() => {
const links = Array.from(
typeof selectors === 'string' ? root.querySelectorAll<HTMLAnchorElement>(selectors) : selectors
).filter(shouldPreload);
for (const link of links) {
observe(link);
}
});
}

View file

@ -0,0 +1,17 @@
import type { AstroIntegration } from 'astro';
import type { PrefetchOptions } from './client.js';
export default function (options: PrefetchOptions = {}): AstroIntegration {
return {
name: '@astrojs/lit',
hooks: {
'astro:config:setup': ({ updateConfig, addRenderer, injectScript }) => {
// Inject the necessary polyfills on every page (inlined for speed).
injectScript(
'page',
`import prefetch from "@astrojs/prefetch/client.js"; prefetch(${JSON.stringify(options)});`
);
}
}
};
}

View file

@ -0,0 +1,16 @@
function shim(callback: IdleRequestCallback, options?: IdleRequestOptions): NodeJS.Timeout {
const timeout = options?.timeout ?? 50;
const start = Date.now();
return setTimeout(function () {
callback({
didTimeout: false,
timeRemaining: function () {
return Math.max(0, timeout - (Date.now() - start));
},
});
}, 1);
}
const requestIdleCallback = window.requestIdleCallback || shim;
export default requestIdleCallback;

View file

@ -0,0 +1,62 @@
import { expect } from '@playwright/test';
import { testFactory } from './test-utils.js';
const test = testFactory({ root: './fixtures/basic-prefetch/' });
test.describe('dev', () => {
let devServer;
test.beforeEach(async ({ astro }) => {
devServer = await astro.startDevServer();
});
test.afterEach(async () => {
await devServer.stop();
});
test.describe('prefetches rel="prefetch" links', () => {
test('skips /admin', async ({ page, astro }) => {
const requests = new Set();
page.on('request', async (request) => requests.add(request.url()));
await page.goto(astro.resolveUrl('/'));
await page.waitForLoadState('networkidle');
await expect(requests.has(astro.resolveUrl('/about'))).toBeTruthy();
await expect(requests.has(astro.resolveUrl('/contact'))).toBeTruthy();
await expect(requests.has(astro.resolveUrl('/admin'))).toBeFalsy();
});
});
});
test.describe('build', () => {
let previewServer;
test.beforeAll(async ({ astro }) => {
await astro.build();
previewServer = await astro.preview();
});
// important: close preview server (free up port and connection)
test.afterAll(async () => {
await previewServer.stop();
});
test.describe('prefetches rel="prefetch" links', () => {
test('skips /admin', async ({ page, astro }) => {
const requests = new Set();
page.on('request', async (request) => requests.add(request.url()));
await page.goto(astro.resolveUrl('/'));
await page.waitForLoadState('networkidle');
await expect(requests.has(astro.resolveUrl('/about'))).toBeTruthy();
await expect(requests.has(astro.resolveUrl('/contact'))).toBeTruthy();
await expect(requests.has(astro.resolveUrl('/admin'))).toBeFalsy();
});
});
});

View file

@ -0,0 +1,7 @@
import { defineConfig } from 'astro/config';
import prefetch from '@astrojs/prefetch';
// https://astro.build/config
export default defineConfig({
integrations: [prefetch()],
});

View file

@ -0,0 +1,9 @@
{
"name": "@test/astro-prefetch",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/prefetch": "workspace:*",
"astro": "workspace:*"
}
}

View file

@ -0,0 +1,11 @@
---
---
<html>
<head>
<title>About Us</title>
</head>
<body>
<h1>About Us</h1>
</body>
</html>

View file

@ -0,0 +1,11 @@
---
---
<html>
<head>
<title>Admin</title>
</head>
<body>
<h1>Admin</h1>
</body>
</html>

View file

@ -0,0 +1,11 @@
---
---
<html>
<head>
<title>Contact Us</title>
</head>
<body>
<h1>Contact Us</h1>
</body>
</html>

View file

@ -0,0 +1,25 @@
---
---
<html>
<head>
<title>Home</title>
</head>
<body>
<h1>Home</h1>
<nav>
<ul>
<li>
<a href="/about" rel="prefetch">About</a>
</li>
<li>
<a href="/contact" rel="prefetch">Contact</a>
</li>
<li>
<a href="/admin">Admin</a>
</li>
</ul>
</nav>
</body>
</html>

View file

@ -0,0 +1,31 @@
import { test as testBase } from '@playwright/test';
import { loadFixture as baseLoadFixture } from '../../../astro/test/test-utils.js';
export function loadFixture(inlineConfig) {
if (!inlineConfig || !inlineConfig.root)
throw new Error("Must provide { root: './fixtures/...' }");
// resolve the relative root (i.e. "./fixtures/tailwindcss") to a full filepath
// without this, the main `loadFixture` helper will resolve relative to `packages/astro/test`
return baseLoadFixture({
...inlineConfig,
root: new URL(inlineConfig.root, import.meta.url).toString(),
});
}
export function testFactory(inlineConfig) {
let fixture;
const test = testBase.extend({
astro: async ({}, use) => {
fixture = await loadFixture(inlineConfig);
await use(fixture);
},
});
test.afterEach(() => {
fixture.resetAllFiles();
});
return test;
}

View file

@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"include": ["src"],
"compilerOptions": {
"allowJs": true,
"module": "ES2020",
"outDir": "./dist",
"target": "ES2020",
}
}

View file

@ -2104,6 +2104,33 @@ importers:
astro-scripts: link:../../../scripts
vue: 3.2.37
packages/labs/prefetch:
specifiers:
'@types/chai': ^4.3.1
'@types/chai-as-promised': ^7.1.5
'@types/mocha': ^9.1.1
astro: workspace:*
astro-scripts: workspace:*
playwright: ^1.22.2
throttles: ^1.0.1
dependencies:
throttles: 1.0.1
devDependencies:
'@types/chai': 4.3.1
'@types/chai-as-promised': 7.1.5
'@types/mocha': 9.1.1
astro: link:../../astro
astro-scripts: link:../../../scripts
playwright: 1.22.2
packages/labs/prefetch/test/fixtures/basic-prefetch:
specifiers:
'@astrojs/prefetch': workspace:*
astro: workspace:*
dependencies:
'@astrojs/prefetch': link:../../..
astro: link:../../../../../astro
packages/markdown/remark:
specifiers:
'@astrojs/micromark-extension-mdx-jsx': ^1.0.3
@ -11998,6 +12025,15 @@ packages:
hasBin: true
dev: true
/playwright/1.22.2:
resolution: {integrity: sha512-hUTpg7LytIl3/O4t0AQJS1V6hWsaSY5uZ7w1oCC8r3a1AQN5d6otIdCkiB3cbzgQkcMaRxisinjMFMVqZkybdQ==}
engines: {node: '>=14'}
hasBin: true
requiresBuild: true
dependencies:
playwright-core: 1.22.2
dev: true
/postcss-attribute-case-insensitive/5.0.1_postcss@8.4.14:
resolution: {integrity: sha512-wrt2VndqSLJpyBRNz9OmJcgnhI9MaongeWgapdBuUMu2a/KNJ8SENesG4SdiTnQwGO9b1VKbTWYAfCPeokLqZQ==}
engines: {node: ^12 || ^14 || >=16}
@ -13742,6 +13778,11 @@ packages:
resolution: {integrity: sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=}
dev: true
/throttles/1.0.1:
resolution: {integrity: sha512-fab7Xg+zELr9KOv4fkaBoe/b3L0GMGLd0IBSCn16GoE/Qx6/OfCr1eGNyEcDU2pUA79qQfZ8kPQWlRuok4YwTw==}
engines: {node: '>=6'}
dev: false
/tiny-glob/0.2.9:
resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==}
dependencies:
@ -14772,7 +14813,7 @@ packages:
dev: false
/wrappy/1.0.2:
resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=}
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
/xregexp/2.0.0:
resolution: {integrity: sha1-UqY+VsoLhKfzpfPWGHLxJq16WUM=}