Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for view transitions #1532

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 201 additions & 0 deletions src/runtime/entrypoints/view_transitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// TS types don't include that yet
declare global {
interface Document {
startViewTransition?(fn: () => void): {
finished: Promise<boolean>;
};
}

interface Navigator {
connection?: NetworkInformation;
}

interface NetworkInformation {
effectiveType: string; // "slow-2g" | "2g" | "3g" | "4g";
saveData: boolean;
}
}

// WARNING: Must be a self contained function
export function initViewTransitions() {
// Keep track of history state to apply forward or backward animations
let index = history.state?.index || 0;
if (!history.state) {
history.replaceState({ index }, document.title);
}

const supportsViewTransitions = document.startViewTransition;
const hasViewTransitions = !!document.head.querySelector(
"#__FRSH_TRANSITIONS",
);
const parser = new DOMParser();

function patchAttrs(oldEl: HTMLElement, newEl: HTMLElement) {
// Remove old attributes not present anymore
const oldAttrs = oldEl.getAttributeNames();
for (let i = 0; i < oldAttrs.length; i++) {
const name = oldAttrs[i];
if (!name.startsWith("data-frsh") && !newEl.hasAttribute(name)) {
oldEl.removeAttribute(name);
}
}

// Add new attributes
const attrs = newEl.getAttributeNames();
for (let i = 0; i < attrs.length; i++) {
const name = attrs[i];
const value = newEl.getAttribute(name);
if (value === null) oldEl.removeAttribute(name);
else if (oldEl.getAttribute(name) !== value) {
oldEl.setAttribute(name, value);
}
}
}

async function updatePage(html: string) {
const doc = parser.parseFromString(html, "text/html");

const existing = new Set(
(Array.from(
document.querySelectorAll('head link[rel="stylesheet"]'),
) as HTMLLinkElement[]).map((el) =>
new URL(el.href, location.origin).toString()
),
);

// Grab all pending styles from the new document and wait
// until they are loaded before beginning the transition. This
// avoids layout flickering
const styles = (Array.from(
doc.querySelectorAll('head link[rel="stylesheet"]'),
) as HTMLLinkElement[])
// Filter out stylesheets that we've already loaded
.filter((el) => !existing.has(el.href))
.map(
(link) => {
const clone = link.cloneNode() as HTMLLinkElement;
return new Promise((resolve, reject) => {
clone.addEventListener("load", resolve);
clone.addEventListener("error", reject);
});
},
);

if (styles.length) {
await Promise.all(styles);
}

// Replacing the full document breaks animations in Chrome, but
// replacing only the <body> works. So we do that and diff the
// <html> and <head> elements ourselves.

// Replacing <head>
document.title = doc.title;

// Patch <html> attributes if there are any
patchAttrs(document.documentElement, doc.documentElement);
// Replace <body>. That's the only way that keeps animations working
document.body.replaceWith(doc.body);
}

async function navigate(url: string, direction: "forward" | "back") {
const res = await fetch(url);
// Abort transition and navigate directly to the target
// when request failed
if (!res.ok) {
location.href = url;
return;
}
const text = await res.text();

// TODO: Error handling?
try {
document.documentElement.setAttribute(
"data-frsh-nav",
direction,
);
await supportsViewTransitions
? document.startViewTransition!(() => updatePage(text)).finished
: updatePage(text);
} catch (_err) {
// Fall back to a classic navigation if an error occurred
location.href = url;
return;
}
}

document.addEventListener("click", async (e) => {
let el = e.target;
if (el && el instanceof HTMLElement) {
// Check if we clicked inside an anchor link
if (el.nodeName !== "A") {
el = el.closest("a");
}

if (
// Check that we're still dealing with an anchor tag
el && el instanceof HTMLAnchorElement &&
// Check if it's an internal link
el.href && (!el.target || el.target === "_self") &&
el.origin === location.origin &&
// Check if it was a left click and not a right click
e.button === 0 &&
// Check that the user doesn't press a key combo to open the
// link in a new tab or something
!(e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.button) &&
// Check that the event isn't aborted already
!e.defaultPrevented
) {
e.preventDefault();

await navigate(el.href, "forward");

index++;
history.pushState({ index }, "", el.href);
}
}
});

// deno-lint-ignore no-window-prefix
window.addEventListener("popstate", async () => {
const nextIdx = history.state?.index ?? index + 1;
const direction = nextIdx > index ? "forward" : "back";
index = nextIdx;
await navigate(location.href, direction);
});

// Prefetch sites when the user starts to click on them. A click
// takes on average 100ms, which means we can fetch the next page
// early.
["mousedown", "touchstart"].forEach((evName) => {
document.addEventListener(evName, (ev) => {
if (ev.target instanceof HTMLAnchorElement) {
const el = ev.target;
if (
el.origin === location.origin && el.pathname !== location.pathname &&
hasViewTransitions
) {
if (
document.querySelector(`link[rel=prefetch][href="${el.pathname}"]`)
) {
return;
}
if (
navigator.connection &&
(navigator.connection.saveData ||
/(2|3)g/.test(navigator.connection.effectiveType || ""))
) {
return;
}
const link = document.createElement("link");
link.setAttribute("rel", "prefetch");
link.setAttribute("href", el.pathname);
document.head.append(link);
}
}
}, {
passive: true,
capture: true,
});
});
}
95 changes: 95 additions & 0 deletions src/server/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ import { bundleAssetUrl } from "./constants.ts";
import { assetHashingHook } from "../runtime/utils.ts";
import { htmlEscapeJsonString } from "./htmlescape.ts";
import { serialize } from "./serializer.ts";
import { initViewTransitions } from "$fresh/src/runtime/entrypoints/view_transitions.ts";
import {
ViewAnimation,
ViewTransitionOpts,
} from "$fresh/src/server/view_transitions.ts";

export const DEFAULT_RENDER_FN: RenderFunction = (_ctx, render) => {
render();
Expand Down Expand Up @@ -201,6 +206,9 @@ export async function render<Data>(
// Clear the island props
ISLAND_PROPS = [];

// Clear encountered view transitions
VIEW_TRANSITIONS = [];

const ctx = new RenderContext(
crypto.randomUUID(),
opts.url,
Expand Down Expand Up @@ -392,9 +400,58 @@ export async function render<Data>(
styleTags.splice(styleTags.length, 0, ...res.styles ?? []);
}

// Add view transition CSS
if (VIEW_TRANSITIONS.length > 0) {
let cssText = "";

for (let i = 0; i < VIEW_TRANSITIONS.length; i++) {
const anim = VIEW_TRANSITIONS[i];

const oldForward = serializeViewAnimation(anim.forward.old);
const oldBackward = serializeViewAnimation(anim.backward.old);
const newForward = serializeViewAnimation(anim.forward.new);
const newBackward = serializeViewAnimation(anim.backward.new);

// Selectors cannot be merged as unknown pseudo selectors
// flag the whole selector group as invalid.
cssText += `[data-frsh-transition="${anim.id}"] {\n`;
cssText += ` view-transition-name: ${anim.id};\n`;
cssText += `}\n`;
cssText += `::view-transition-old(${anim.id}) {\n${oldForward}\n}\n`;
cssText += `::view-transition-new(${anim.id}) {\n${newForward}\n}\n`;

// Backwards for browser navigation
cssText +=
`[data-frsh-nav="back"]::view-transition-old(${anim.id}) {\n${oldBackward}\n}\n`;
cssText +=
`[data-frsh-nav="back"]::view-transition-new(${anim.id}) {\n${newBackward}\n}\n`;
}

// Disable view transitions if the user prefers reduced motion
cssText += `@media (prefers-reduced-motion) {\n`;
cssText += ` ::view-transition-group(*),\n`;
cssText += ` ::view-transition-old(*),\n`;
cssText += ` ::view-transition-new(*) {\n`;
cssText += ` animation: none !important;\n`;
cssText += ` }\n`;
cssText += ` [data-frsh-transition] {\n`;
cssText += ` animation: none !important;\n`;
cssText += ` }\n`;
cssText += `}\n`;

styleTags.push({
cssText,
id: "__FRSH_TRANSITIONS",
});
}

// The inline script that will hydrate the page.
let script = "";

// View transitions
// TODO: Add conditionally
script += initViewTransitions.toString() + "\ninitViewTransitions();\n";

// Serialize the state into the <script id=__FRSH_STATE> tag and generate the
// inline script to deserialize it. This script starts by deserializing the
// state in the tag. This potentially requires importing @preact/signals.
Expand Down Expand Up @@ -485,6 +542,32 @@ export async function render<Data>(
return [html, csp];
}

function serializeViewAnimation(anims: ViewAnimation | ViewAnimation[]) {
if (!Array.isArray(anims)) {
anims = [anims];
}

let durations = " animation-duration: ";
let timingFn = " animation-timing-function: ";
let fillMode = " animation-fill-mode: ";
let delay = " animation-delay: ";
let direction = " animation-direction: ";
let name = " animation-name: ";

for (let i = 0; i < anims.length; i++) {
const anim = anims[i];
const comma = i > 0 ? ", " : "";
durations += comma + (anim.duration ?? "auto");
timingFn += comma + (anim.easing ?? "ease");
fillMode += comma + (anim.fillMode ?? "none");
delay += comma + (anim.delay ?? "0s");
direction += comma + (anim.direction ?? "normal");
name += comma + anim.name;
}
return [durations, timingFn, fillMode, delay, direction, name].join(";\n") +
"\n";
}

export interface TemplateOptions {
bodyHtml: string;
headComponents: ComponentChildren[];
Expand Down Expand Up @@ -560,6 +643,9 @@ const ISLANDS: Island[] = [];
const ENCOUNTERED_ISLANDS: Set<Island> = new Set([]);
let ISLAND_PROPS: unknown[] = [];

// Keep track of view transitions
let VIEW_TRANSITIONS: ViewTransitionOpts[] = [];

// Keep track of which component rendered which vnode. This allows us
// to detect when an island is rendered within another instead of being
// passed as children.
Expand Down Expand Up @@ -642,6 +728,15 @@ options.vnode = (vnode) => {
props["ON" + key.slice(2)] = value;
}
}

// View transitions
if ("transition" in props) {
const anim = props.transition as ViewTransitionOpts;
VIEW_TRANSITIONS.push(anim);
delete props.transition;
// deno-lint-ignore no-explicit-any
(vnode.props as any)["data-frsh-transition"] = anim.id;
}
}

if (originalHook) originalHook(vnode);
Expand Down
28 changes: 28 additions & 0 deletions src/server/view_transitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export interface ViewAnimation {
name: string;
delay?: number | string;
duration?: number | string;
easing?: string;
fillMode?: string;
direction?: string;
}

export interface ViewTransitionOpts {
id: string;
forward: {
old: ViewAnimation | ViewAnimation[];
new: ViewAnimation | ViewAnimation[];
};
backward: {
old: ViewAnimation | ViewAnimation[];
new: ViewAnimation | ViewAnimation[];
};
}

declare global {
namespace preact.createElement.JSX {
interface HTMLAttributes {
transition?: ViewTransitionOpts;
}
}
}
5 changes: 5 additions & 0 deletions tests/fixture_view_transitions/dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env -S deno run -A --watch=static/,routes/

import dev from "$fresh/dev.ts";

await dev(import.meta.url, "./main.ts");
19 changes: 19 additions & 0 deletions tests/fixture_view_transitions/fresh.gen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// DO NOT EDIT. This file is generated by Fresh.
// This file SHOULD be checked into source version control.
// This file is automatically updated during development when running `dev.ts`.

import * as $0 from "./routes/_app.tsx";
import * as $1 from "./routes/index.tsx";
import * as $2 from "./routes/other.tsx";

const manifest = {
routes: {
"./routes/_app.tsx": $0,
"./routes/index.tsx": $1,
"./routes/other.tsx": $2,
},
islands: {},
baseUrl: import.meta.url,
};

export default manifest;
Loading