Skip to content

Commit

Permalink
Support View Transition API for navigations (#935)
Browse files Browse the repository at this point in the history
Adds the ability to use the View Transitions API. It's based around the
MPA View Transitions support which is currently becoming available in
Chrome.

When navigating between pages, we check for the presence of
`view-transition` meta tags in both documents, with a value of
`same-origin`. If those tags are present, and the browser supports the
View Transitions API, we can render the page update within a transition.

When not opted in with the meta tags, or when support is not available,
we fallback to the previous behaviour.

This mimics the behaviour that a supporting browser would have when
performing a full-page navigation between those pages.

We also suppress snapshot caching on pages that specify these meta tags,
since the snapshots would interfere with the animations.

Note that while the API is based on MPA view transitions, the
implementation only requires SPA view transitions, which is already
available in the latest Chrome versions.

Also note that this is based on an API that is very new, and not yet
widely supported. We'll want to keep an eye on how that develops and
update our implementation accordingly.
  • Loading branch information
kevinmcconnell committed Jun 27, 2023
1 parent 96a4f58 commit 151aca2
Show file tree
Hide file tree
Showing 7 changed files with 132 additions and 3 deletions.
8 changes: 6 additions & 2 deletions src/core/drive/page_snapshot.ts
Expand Up @@ -56,17 +56,21 @@ export class PageSnapshot extends Snapshot<HTMLBodyElement> {
}

get isPreviewable() {
return this.cacheControlValue != "no-preview"
return this.cacheControlValue != "no-preview" && !this.prefersViewTransitions
}

get isCacheable() {
return this.cacheControlValue != "no-cache"
return this.cacheControlValue != "no-cache" && !this.prefersViewTransitions
}

get isVisitable() {
return this.getSetting("visit-control") != "reload"
}

get prefersViewTransitions() {
return this.headSnapshot.getMetaValue("view-transition") === "same-origin"
}

// Private

getSetting(name: string) {
Expand Down
6 changes: 5 additions & 1 deletion src/core/drive/page_view.ts
Expand Up @@ -4,6 +4,7 @@ import { ErrorRenderer } from "./error_renderer"
import { PageRenderer } from "./page_renderer"
import { PageSnapshot } from "./page_snapshot"
import { SnapshotCache } from "./snapshot_cache"
import { withViewTransition } from "./view_transitions"
import { Visit } from "./visit"

export type PageViewRenderOptions = ViewRenderOptions<HTMLBodyElement>
Expand All @@ -20,13 +21,16 @@ export class PageView extends View<HTMLBodyElement, PageSnapshot, PageViewRender
forceReloaded = false

renderPage(snapshot: PageSnapshot, isPreview = false, willRender = true, visit?: Visit) {
const shouldTransition = this.snapshot.prefersViewTransitions && snapshot.prefersViewTransitions
const renderer = new PageRenderer(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender)

if (!renderer.shouldRender) {
this.forceReloaded = true
} else {
visit?.changeHistory()
}
return this.render(renderer)

return withViewTransition(shouldTransition, () => this.render(renderer))
}

renderError(snapshot: PageSnapshot, visit?: Visit) {
Expand Down
17 changes: 17 additions & 0 deletions src/core/drive/view_transitions.ts
@@ -0,0 +1,17 @@
declare global {
type ViewTransition = {
finished: Promise<void>
}

interface Document {
startViewTransition?(callback: () => void): ViewTransition
}
}

export function withViewTransition(shouldTransition: boolean, callback: () => Promise<void>): Promise<void> {
if (shouldTransition && document.startViewTransition) {
return document.startViewTransition(callback).finished
} else {
return callback()
}
}
31 changes: 31 additions & 0 deletions src/tests/fixtures/transitions/left.html
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Left</title>
<meta name="view-transition" content="same-origin" />
<script src="/dist/turbo.es2017-umd.js" data-turbo-track="reload"></script>

<style>
.square {
display: block;
width: 100px;
height: 100px;
border-radius: 6px;
background-color: blue;
view-transition-name: square;
}

.square.right {
margin-left: auto;
}
</style>
</head>

<body style="background-color: orange">
<h1>Left</h1>
<p><a id="go-right" href="/src/tests/fixtures/transitions/right.html">go right</a></p>
<div class="square"></div>
<p><a id="go-other" href="/src/tests/fixtures/transitions/other.html">go other</a></p>
</body>
</html>
13 changes: 13 additions & 0 deletions src/tests/fixtures/transitions/other.html
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Other</title>
<script src="/dist/turbo.es2017-umd.js" data-turbo-track="reload"></script>
</head>

<body style="background-color: yellow">
<h1>Other</h1>
<p><a id="go-left" href="/src/tests/fixtures/transitions/left.html">go left</a></p>
</body>
</html>
30 changes: 30 additions & 0 deletions src/tests/fixtures/transitions/right.html
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Right</title>
<meta name="view-transition" content="same-origin" />
<script src="/dist/turbo.es2017-umd.js" data-turbo-track="reload"></script>

<style>
.square {
display: block;
width: 100px;
height: 100px;
border-radius: 6px;
background-color: blue;
view-transition-name: square;
}

.square.right {
margin-left: auto;
}
</style>
</head>

<body style="background-color: red">
<h1>Right</h1>
<p><a id="go-left" href="/src/tests/fixtures/transitions/left.html">go left</a></p>
<div class="square right"></div>
</body>
</html>
30 changes: 30 additions & 0 deletions src/tests/functional/drive_view_transition_tests.ts
@@ -0,0 +1,30 @@
import { test } from "@playwright/test"
import { assert } from "chai"
import { nextBody } from "../helpers/page"

test.beforeEach(async ({ page }) => {
await page.goto("/src/tests/fixtures/transitions/left.html")

await page.evaluate(`
document.startViewTransition = (callback) => {
window.startViewTransitionCalled = true
callback()
}
`)
})

test("navigating triggers the view transition", async ({ page }) => {
await page.locator("#go-right").click()
await nextBody(page)

const called = await page.evaluate(`window.startViewTransitionCalled`)
assert.isTrue(called)
})

test("navigating does not trigger a view transition when meta tag not present", async ({ page }) => {
await page.locator("#go-other").click()
await nextBody(page)

const called = await page.evaluate(`window.startViewTransitionCalled`)
assert.isUndefined(called)
})

0 comments on commit 151aca2

Please sign in to comment.