Adds a basic @astrojs/prefetch integration
This commit is contained in:
parent
c3d41d1f60
commit
7ebc51fc27
15 changed files with 383 additions and 1 deletions
3
packages/labs/prefetch/README.md
Normal file
3
packages/labs/prefetch/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# @astrojs/prefetch 🔗
|
||||
|
||||
TODO
|
39
packages/labs/prefetch/package.json
Normal file
39
packages/labs/prefetch/package.json
Normal 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"
|
||||
}
|
||||
}
|
89
packages/labs/prefetch/src/client.ts
Normal file
89
packages/labs/prefetch/src/client.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
17
packages/labs/prefetch/src/index.ts
Normal file
17
packages/labs/prefetch/src/index.ts
Normal 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)});`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
16
packages/labs/prefetch/src/requestIdleCallback.ts
Normal file
16
packages/labs/prefetch/src/requestIdleCallback.ts
Normal 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;
|
62
packages/labs/prefetch/test/basic-prefetch.test.js
Normal file
62
packages/labs/prefetch/test/basic-prefetch.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
7
packages/labs/prefetch/test/fixtures/basic-prefetch/astro.config.mjs
vendored
Normal file
7
packages/labs/prefetch/test/fixtures/basic-prefetch/astro.config.mjs
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import prefetch from '@astrojs/prefetch';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [prefetch()],
|
||||
});
|
9
packages/labs/prefetch/test/fixtures/basic-prefetch/package.json
vendored
Normal file
9
packages/labs/prefetch/test/fixtures/basic-prefetch/package.json
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "@test/astro-prefetch",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@astrojs/prefetch": "workspace:*",
|
||||
"astro": "workspace:*"
|
||||
}
|
||||
}
|
11
packages/labs/prefetch/test/fixtures/basic-prefetch/src/pages/about.astro
vendored
Normal file
11
packages/labs/prefetch/test/fixtures/basic-prefetch/src/pages/about.astro
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
---
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>About Us</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>About Us</h1>
|
||||
</body>
|
||||
</html>
|
11
packages/labs/prefetch/test/fixtures/basic-prefetch/src/pages/admin.astro
vendored
Normal file
11
packages/labs/prefetch/test/fixtures/basic-prefetch/src/pages/admin.astro
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
---
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Admin</h1>
|
||||
</body>
|
||||
</html>
|
11
packages/labs/prefetch/test/fixtures/basic-prefetch/src/pages/contact.astro
vendored
Normal file
11
packages/labs/prefetch/test/fixtures/basic-prefetch/src/pages/contact.astro
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
---
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>Contact Us</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Contact Us</h1>
|
||||
</body>
|
||||
</html>
|
25
packages/labs/prefetch/test/fixtures/basic-prefetch/src/pages/index.astro
vendored
Normal file
25
packages/labs/prefetch/test/fixtures/basic-prefetch/src/pages/index.astro
vendored
Normal 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>
|
31
packages/labs/prefetch/test/test-utils.js
Normal file
31
packages/labs/prefetch/test/test-utils.js
Normal 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;
|
||||
}
|
10
packages/labs/prefetch/tsconfig.json
Normal file
10
packages/labs/prefetch/tsconfig.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"module": "ES2020",
|
||||
"outDir": "./dist",
|
||||
"target": "ES2020",
|
||||
}
|
||||
}
|
|
@ -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=}
|
||||
|
|
Loading…
Reference in a new issue