diff --git a/src/core/drive/preloader.ts b/src/core/drive/preloader.ts new file mode 100644 index 000000000..d8829de43 --- /dev/null +++ b/src/core/drive/preloader.ts @@ -0,0 +1,54 @@ +import { Navigator } from "./navigator" +import { PageSnapshot } from "./page_snapshot" +import { SnapshotCache } from "./snapshot_cache" + +export interface PreloaderDelegate { + readonly navigator: Navigator +} + +export class Preloader { + readonly delegate: PreloaderDelegate + readonly selector: string = 'a[data-turbo-preload]' + + constructor(delegate: PreloaderDelegate) { + this.delegate = delegate + } + + get snapshotCache(): SnapshotCache { + return this.delegate.navigator.view.snapshotCache + } + + start() { + if (document.readyState === 'loading') { + return document.addEventListener('DOMContentLoaded', () => { + this.preloadOnLoadLinksForView(document.body) + }); + } else { + this.preloadOnLoadLinksForView(document.body) + } + } + + preloadOnLoadLinksForView(element: Element) { + for (const link of element.querySelectorAll(this.selector)) { + this.preloadURL(link) + } + } + + async preloadURL(link: HTMLAnchorElement) { + const location = new URL(link.href) + + if (this.snapshotCache.has(location)) { + return + } + + try { + const response = await fetch(location.toString(), { headers: { 'VND.PREFETCH': 'true', 'Accept': 'text/html' } }) + const responseText = await response.text() + const snapshot = PageSnapshot.fromHTMLString(responseText) + + this.snapshotCache.put(location, snapshot) + } catch(_) { + // If we cannot preload that is ok! + } + } +} diff --git a/src/core/frames/frame_controller.ts b/src/core/frames/frame_controller.ts index 9aad69841..85d514387 100644 --- a/src/core/frames/frame_controller.ts +++ b/src/core/frames/frame_controller.ts @@ -243,6 +243,10 @@ export class FrameController viewRenderedSnapshot(_snapshot: Snapshot, _isPreview: boolean) {} + preloadOnLoadLinksForView(element: Element) { + session.preloadOnLoadLinksForView(element) + } + viewInvalidated() {} // Private diff --git a/src/core/session.ts b/src/core/session.ts index 7ae27bed5..f0b68254f 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -18,6 +18,7 @@ import { Visit, VisitOptions } from "./drive/visit" import { PageSnapshot } from "./drive/page_snapshot" import { FrameElement } from "../elements/frame_element" import { FetchResponse } from "../http/fetch_response" +import { Preloader, PreloaderDelegate } from "./drive/preloader" export type TimingData = unknown @@ -28,10 +29,12 @@ export class Session LinkClickObserverDelegate, NavigatorDelegate, PageObserverDelegate, - PageViewDelegate + PageViewDelegate, + PreloaderDelegate { readonly navigator = new Navigator(this) readonly history = new History(this) + readonly preloader = new Preloader(this) readonly view = new PageView(this, document.documentElement) adapter: Adapter = new BrowserAdapter(this) @@ -59,6 +62,7 @@ export class Session this.streamObserver.start() this.frameRedirector.start() this.history.start() + this.preloader.start() this.started = true this.enabled = true } @@ -267,6 +271,10 @@ export class Session this.notifyApplicationAfterRender() } + preloadOnLoadLinksForView(element: Element) { + this.preloader.preloadOnLoadLinksForView(element) + } + viewInvalidated(reason: ReloadReason) { this.adapter.pageInvalidated(reason) } diff --git a/src/core/view.ts b/src/core/view.ts index aadb11444..db2774dab 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -6,6 +6,7 @@ import { getAnchor } from "./url" export interface ViewDelegate { allowsImmediateRender(snapshot: S, resume: (value: any) => void): boolean + preloadOnLoadLinksForView(element: Element): void viewRenderedSnapshot(snapshot: S, isPreview: boolean): void viewInvalidated(reason: ReloadReason): void } @@ -89,6 +90,7 @@ export abstract class View< await this.renderSnapshot(renderer) this.delegate.viewRenderedSnapshot(snapshot, isPreview) + this.delegate.preloadOnLoadLinksForView(this.element) this.finishRenderingSnapshot(renderer) } finally { delete this.renderer diff --git a/src/tests/fixtures/frame_preloading.html b/src/tests/fixtures/frame_preloading.html new file mode 100644 index 000000000..4b8fd7a38 --- /dev/null +++ b/src/tests/fixtures/frame_preloading.html @@ -0,0 +1,14 @@ + + + + + + Page With Preloading Frame + + + + + + + + diff --git a/src/tests/fixtures/frames/preloading.html b/src/tests/fixtures/frames/preloading.html new file mode 100644 index 000000000..08b4860b1 --- /dev/null +++ b/src/tests/fixtures/frames/preloading.html @@ -0,0 +1,4 @@ + + Visit preloaded + page + diff --git a/src/tests/fixtures/hot_preloading.html b/src/tests/fixtures/hot_preloading.html new file mode 100644 index 000000000..0a9d511c5 --- /dev/null +++ b/src/tests/fixtures/hot_preloading.html @@ -0,0 +1,14 @@ + + + + + + Page That Links to Preloading Page + + + + + Next page has preloading + + + diff --git a/src/tests/fixtures/preloaded.html b/src/tests/fixtures/preloaded.html new file mode 100644 index 000000000..9b34768fb --- /dev/null +++ b/src/tests/fixtures/preloaded.html @@ -0,0 +1,16 @@ + + + + + + Preloaded Page + + + + +
+ This page was hopefully preloaded +
+ + + diff --git a/src/tests/fixtures/preloading.html b/src/tests/fixtures/preloading.html new file mode 100644 index 000000000..ad3a6d0b2 --- /dev/null +++ b/src/tests/fixtures/preloading.html @@ -0,0 +1,16 @@ + + + + + + Preloading Page + + + + + + Visit preloaded page + + + + diff --git a/src/tests/functional/index.ts b/src/tests/functional/index.ts index 2c5c469bd..2cd8bbd4f 100644 --- a/src/tests/functional/index.ts +++ b/src/tests/functional/index.ts @@ -11,6 +11,7 @@ export * from "./loading_tests" export * from "./navigation_tests" export * from "./pausable_rendering_tests" export * from "./pausable_requests_tests" +export * from "./preloader_tests" export * from "./rendering_tests" export * from "./scroll_restoration_tests" export * from "./stream_tests" diff --git a/src/tests/functional/preloader_tests.ts b/src/tests/functional/preloader_tests.ts new file mode 100644 index 000000000..c332c6509 --- /dev/null +++ b/src/tests/functional/preloader_tests.ts @@ -0,0 +1,49 @@ +import { TurboDriveTestCase } from "../helpers/turbo_drive_test_case" + +export class PreloaderTests extends TurboDriveTestCase { + async "test preloads snapshot on initial load"() { + // contains `a[rel="preload"][href="http://localhost:9000/src/tests/fixtures/preloaded.html"]` + await this.goToLocation("/src/tests/fixtures/preloading.html") + await this.nextBeat + + this.assert.ok(await this.remote.execute(() => { + const preloadedUrl = "http://localhost:9000/src/tests/fixtures/preloaded.html" + const cache = window.Turbo.session.preloader.snapshotCache.snapshots + + return preloadedUrl in cache + })) + } + + async "test preloads snapshot on page visit"() { + // contains `a[rel="preload"][href="http://localhost:9000/src/tests/fixtures/preloading.html"]` + await this.goToLocation("/src/tests/fixtures/hot_preloading.html") + + // contains `a[rel="preload"][href="http://localhost:9000/src/tests/fixtures/preloaded.html"]` + await this.clickSelector("#hot_preload_anchor") + await this.waitUntilSelector("#preload_anchor") + await this.nextBeat + + this.assert.ok(await this.remote.execute(() => { + const preloadedUrl = "http://localhost:9000/src/tests/fixtures/preloaded.html" + const cache = window.Turbo.session.preloader.snapshotCache.snapshots + + return preloadedUrl in cache + })) + } + + async "test navigates to preloaded snapshot from frame"() { + // contains `a[rel="preload"][href="http://localhost:9000/src/tests/fixtures/preloaded.html"]` + await this.goToLocation("/src/tests/fixtures/frame_preloading.html") + await this.waitUntilSelector("#frame_preload_anchor") + await this.nextBeat + + this.assert.ok(await this.remote.execute(() => { + const preloadedUrl = "http://localhost:9000/src/tests/fixtures/preloaded.html" + const cache = window.Turbo.session.preloader.snapshotCache.snapshots + + return preloadedUrl in cache + })) + } +} + +PreloaderTests.registerSuite()