diff --git a/src/core/cache.ts b/src/core/cache.ts index 715b7d098..4f089be3d 100644 --- a/src/core/cache.ts +++ b/src/core/cache.ts @@ -1,5 +1,7 @@ import { Session } from "./session" import { setMetaContent } from "../util" +import { SnapshotCache } from "./drive/snapshot_cache" +import { CacheStore } from "./drive/cache_store" export class Cache { readonly session: Session @@ -9,7 +11,7 @@ export class Cache { } clear() { - this.session.clearCache() + this.store.clear() } resetCacheControl() { @@ -24,6 +26,18 @@ export class Cache { this.setCacheControl("no-preview") } + set store(store: string | CacheStore) { + if (typeof store === "string") { + SnapshotCache.setStore(store) + } else { + SnapshotCache.currentStore = store + } + } + + get store(): CacheStore { + return SnapshotCache.currentStore + } + private setCacheControl(value: string) { setMetaContent("turbo-cache-control", value) } diff --git a/src/core/drive/cache_store.ts b/src/core/drive/cache_store.ts new file mode 100644 index 000000000..a9e54c039 --- /dev/null +++ b/src/core/drive/cache_store.ts @@ -0,0 +1,8 @@ +import { PageSnapshot } from "./page_snapshot" + +export abstract class CacheStore { + abstract has(location: URL): Promise + abstract get(location: URL): Promise + abstract put(location: URL, snapshot: PageSnapshot): Promise + abstract clear(): Promise +} diff --git a/src/core/drive/cache_stores/browser_cache_store.ts b/src/core/drive/cache_stores/browser_cache_store.ts new file mode 100644 index 000000000..27888102f --- /dev/null +++ b/src/core/drive/cache_stores/browser_cache_store.ts @@ -0,0 +1,54 @@ +import { CacheStore } from "../cache_store" +import { PageSnapshot } from "../page_snapshot" + +export class BrowserCacheStore extends CacheStore { + _version = "v1" + storage!: Cache + + constructor() { + super() + this.initialize() + } + + async initialize() { + this.storage = await caches.open(`turbo-${this.version}`) + } + + async has(location: URL) { + return (await this.storage.match(location)) !== undefined + } + + async get(location: URL) { + const response = await this.storage.match(location) + if (response && response.ok) { + const html = await response.text() + return PageSnapshot.fromHTMLString(html) + } + } + + async put(location: URL, snapshot: PageSnapshot) { + const response = new Response(snapshot.html, { + status: 200, + statusText: "OK", + headers: { + "Content-Type": "text/html", + }, + }) + await this.storage.put(location, response) + return snapshot + } + + async clear() { + const keys = await this.storage.keys() + await Promise.all(keys.map((key) => this.storage.delete(key))) + } + + set version(value: string) { + this._version = value + this.initialize() + } + + get version() { + return this._version + } +} diff --git a/src/core/drive/cache_stores/memory_store.ts b/src/core/drive/cache_stores/memory_store.ts new file mode 100644 index 000000000..4d0461dab --- /dev/null +++ b/src/core/drive/cache_stores/memory_store.ts @@ -0,0 +1,60 @@ +import { toCacheKey } from "../../url" +import { PageSnapshot } from "../page_snapshot" +import { CacheStore } from "../cache_store" + +export class MemoryStore extends CacheStore { + readonly keys: string[] = [] + readonly size: number + snapshots: { [url: string]: PageSnapshot } = {} + + constructor(size: number) { + super() + this.size = size + } + + async has(location: URL) { + return toCacheKey(location) in this.snapshots + } + + async get(location: URL): Promise { + if (await this.has(location)) { + const snapshot = this.read(location) + this.touch(location) + return snapshot + } + } + + async put(location: URL, snapshot: PageSnapshot): Promise { + this.write(location, snapshot) + this.touch(location) + return snapshot + } + + async clear() { + this.snapshots = {} + } + + // Private + + read(location: URL) { + return this.snapshots[toCacheKey(location)] + } + + write(location: URL, snapshot: PageSnapshot) { + this.snapshots[toCacheKey(location)] = snapshot + } + + touch(location: URL) { + const key = toCacheKey(location) + const index = this.keys.indexOf(key) + if (index > -1) this.keys.splice(index, 1) + this.keys.unshift(key) + this.trim() + } + + trim() { + for (const key of this.keys.splice(this.size)) { + delete this.snapshots[key] + } + } +} diff --git a/src/core/drive/page_snapshot.ts b/src/core/drive/page_snapshot.ts index 4da80ca31..46d04e26f 100644 --- a/src/core/drive/page_snapshot.ts +++ b/src/core/drive/page_snapshot.ts @@ -42,6 +42,10 @@ export class PageSnapshot extends Snapshot { return new PageSnapshot(clonedElement, this.headSnapshot) } + get html() { + return `${this.headElement.outerHTML}\n\n${this.element.outerHTML}` + } + get headElement() { return this.headSnapshot.element } diff --git a/src/core/drive/page_view.ts b/src/core/drive/page_view.ts index 632b4665e..e6a6ddd3b 100644 --- a/src/core/drive/page_view.ts +++ b/src/core/drive/page_view.ts @@ -16,7 +16,7 @@ export interface PageViewDelegate extends ViewDelegate { - readonly snapshotCache = new SnapshotCache(10) + readonly snapshotCache = new SnapshotCache() lastRenderedLocation = new URL(location.href) forceReloaded = false @@ -39,6 +39,10 @@ export class PageView extends View -1) this.keys.splice(index, 1) - this.keys.unshift(key) - this.trim() - } - - trim() { - for (const key of this.keys.splice(this.size)) { - delete this.snapshots[key] - } + return SnapshotCache.currentStore.clear() } } diff --git a/src/core/drive/visit.ts b/src/core/drive/visit.ts index 6028f579b..794f65494 100644 --- a/src/core/drive/visit.ts +++ b/src/core/drive/visit.ts @@ -271,8 +271,8 @@ export class Visit implements FetchRequestDelegate { } } - getCachedSnapshot() { - const snapshot = this.view.getCachedSnapshotForLocation(this.location) || this.getPreloadedSnapshot() + async getCachedSnapshot() { + const snapshot = (await this.view.getCachedSnapshotForLocation(this.location)) || this.getPreloadedSnapshot() if (snapshot && (!getAnchor(this.location) || snapshot.hasAnchor(getAnchor(this.location)))) { if (this.action == "restore" || snapshot.isPreviewable) { @@ -291,8 +291,8 @@ export class Visit implements FetchRequestDelegate { return this.getCachedSnapshot() != null } - loadCachedSnapshot() { - const snapshot = this.getCachedSnapshot() + async loadCachedSnapshot() { + const snapshot = await this.getCachedSnapshot() if (snapshot) { const isPreview = this.shouldIssueRequest() this.render(async () => { diff --git a/src/tests/fixtures/browser_cache.html b/src/tests/fixtures/browser_cache.html new file mode 100644 index 000000000..74f59dae5 --- /dev/null +++ b/src/tests/fixtures/browser_cache.html @@ -0,0 +1,49 @@ + + + + + + + Turbo + + + + + +

Cached pages:

+
    + +

    Links:

    + + + + + diff --git a/src/tests/functional/browser_cache_tests.ts b/src/tests/functional/browser_cache_tests.ts new file mode 100644 index 000000000..f239f01ae --- /dev/null +++ b/src/tests/functional/browser_cache_tests.ts @@ -0,0 +1,58 @@ +import { Page, test, expect } from "@playwright/test" +import { nextBody } from "../helpers/page" + +const path = "/src/tests/fixtures/browser_cache.html" + +test.beforeEach(async ({ page }) => { + await page.goto(path) +}) + +test("test stores pages in the http cache", async ({ page }) => { + await assertCachedURLs(page, []) + + page.click("#second-link") + await nextBody(page) + + await assertCachedURLs(page, ["http://localhost:9000/src/tests/fixtures/browser_cache.html"]) + + page.click("#third-link") + await nextBody(page) + + await assertCachedURLs(page, [ + "http://localhost:9000/src/tests/fixtures/browser_cache.html", + "http://localhost:9000/src/tests/fixtures/browser_cache.html?page=2", + ]) + + // Cache persists across reloads + await page.reload() + + await assertCachedURLs(page, [ + "http://localhost:9000/src/tests/fixtures/browser_cache.html", + "http://localhost:9000/src/tests/fixtures/browser_cache.html?page=2", + ]) +}) + +test("can clear the http cache", async ({ page }) => { + page.click("#second-link") + await nextBody(page) + + await assertCachedURLs(page, ["http://localhost:9000/src/tests/fixtures/browser_cache.html"]) + + page.click("#clear-cache") + await assertCachedURLs(page, []) + + await page.reload() + await assertCachedURLs(page, []) +}) + +const assertCachedURLs = async (page: Page, urls: string[]) => { + if (urls.length == 0) { + await expect(page.locator("#caches")).toBeEmpty() + } else { + await Promise.all( + urls.map((url) => { + return expect(page.locator("#caches")).toContainText(url) + }) + ) + } +} diff --git a/src/tests/functional/preloader_tests.ts b/src/tests/functional/preloader_tests.ts index 3faac3dfd..ecd7ca619 100644 --- a/src/tests/functional/preloader_tests.ts +++ b/src/tests/functional/preloader_tests.ts @@ -8,11 +8,11 @@ test("test preloads snapshot on initial load", async ({ page }) => { await nextBeat() assert.ok( - await page.evaluate(() => { - const preloadedUrl = "http://localhost:9000/src/tests/fixtures/preloaded.html" - const cache = window.Turbo.session.preloader.snapshotCache.snapshots + await page.evaluate(async () => { + const preloadedUrl = new URL("http://localhost:9000/src/tests/fixtures/preloaded.html") + const cache = window.Turbo.session.preloader.snapshotCache - return preloadedUrl in cache + return await cache.has(preloadedUrl) }) ) }) @@ -27,11 +27,11 @@ test("test preloads snapshot on page visit", async ({ page }) => { await nextBeat() assert.ok( - await page.evaluate(() => { - const preloadedUrl = "http://localhost:9000/src/tests/fixtures/preloaded.html" - const cache = window.Turbo.session.preloader.snapshotCache.snapshots + await page.evaluate(async () => { + const preloadedUrl = new URL("http://localhost:9000/src/tests/fixtures/preloaded.html") + const cache = window.Turbo.session.preloader.snapshotCache - return preloadedUrl in cache + return await cache.has(preloadedUrl) }) ) }) @@ -43,11 +43,11 @@ test("test navigates to preloaded snapshot from frame", async ({ page }) => { await nextBeat() assert.ok( - await page.evaluate(() => { - const preloadedUrl = "http://localhost:9000/src/tests/fixtures/preloaded.html" - const cache = window.Turbo.session.preloader.snapshotCache.snapshots + await page.evaluate(async () => { + const preloadedUrl = new URL("http://localhost:9000/src/tests/fixtures/preloaded.html") + const cache = window.Turbo.session.preloader.snapshotCache - return preloadedUrl in cache + return await cache.has(preloadedUrl) }) ) })