@virtualstate/app-history
Version:
Native JavaScript [app-history](https://github.com/WICG/app-history) implementation
932 lines • 36.4 kB
JavaScript
import { ok, assert, isWindowAppHistory } from "../util.js";
import { AsyncEventTarget as EventTarget } from "../../event-target/index.js";
import { fetch } from "./fetch.js";
import { addEventListener, removeEventListener } from "../../event-target/global.js";
import { Response } from "@opennetwork/http-representation";
import { deferred } from "../../util/deferred.js";
export async function initialNavigateThenBack(appHistory) {
let navigateCalled = false, currentChangeCalled = false;
appHistory.addEventListener("navigate", (event) => {
navigateCalled = true;
event.transitionWhile(new Promise(queueMicrotask));
}, { once: true });
appHistory.addEventListener("currentchange", () => {
currentChangeCalled = true;
});
const { committed, finished } = appHistory.navigate("/test", {
state: {
value: 1
}
});
// These are called sync after navigate is called no matter what
assert(navigateCalled);
assert(currentChangeCalled);
// This ties in the async update requirement
const updatedCurrent = appHistory.current;
assert(updatedCurrent);
ok(updatedCurrent.getState().value === 1);
const committedEntry = await committed;
const finishedEntry = await finished;
ok(updatedCurrent === appHistory.current);
ok(finishedEntry === appHistory.current);
ok(committedEntry === finishedEntry);
//
// if (!isWindowAppHistory(appHistory)) {
// let caught;
// if (!appHistory.canGoBack) {
// try {
// await appHistory.back();
// } catch (error) {
// // No initial back
// caught = error;
// }
// }
// assert(caught);
// }
}
export async function initialNavigateThenBackAssigned(appHistory) {
let navigateCalled = false, currentChangeCalled = false;
appHistory.onnavigate = (event) => {
navigateCalled = true;
event.transitionWhile(new Promise(queueMicrotask));
};
appHistory.oncurrentchange = () => {
currentChangeCalled = true;
};
const { committed, finished } = appHistory.navigate("/test", {
state: {
value: 1
}
});
// These are called sync after navigate is called no matter what
assert(navigateCalled);
assert(currentChangeCalled);
// This ties in the async update requirement
const updatedCurrent = appHistory.current;
assert(updatedCurrent);
ok(updatedCurrent.getState().value === 1);
const committedEntry = await committed;
const finishedEntry = await finished;
ok(updatedCurrent === appHistory.current);
ok(finishedEntry === appHistory.current);
ok(committedEntry === finishedEntry);
if (!isWindowAppHistory(appHistory)) {
let caught;
if (!appHistory.canGoBack) {
try {
await appHistory.back();
}
catch (error) {
// No initial back
caught = error;
}
}
assert(caught);
}
}
export async function routeHandlerExample(appHistory) {
const routesTable = new Map();
function handler(event) {
if (!event.canTransition || event.hashChange) {
return;
}
const url = pathname(event.destination.url);
if (routesTable.has(url)) {
const routeHandler = routesTable.get(url);
if (!routeHandler)
return;
event.transitionWhile(routeHandler());
}
}
appHistory.addEventListener("navigate", handler);
try {
let indexed = 0;
routesTable.set("/test", async () => {
indexed += 1;
});
const { finished } = appHistory.navigate("/test", {
state: {
value: 1
}
});
await finished;
ok(indexed);
}
finally {
appHistory.removeEventListener("navigate", handler);
}
}
export async function productBackButtonClicked(appHistory) {
const backButtonEl = new EventTarget();
let finishedClickNavigation = deferred();
const startingLength = isWindowAppHistory(appHistory) ? appHistory.entries().length : 0;
backButtonEl.addEventListener("click", async () => {
const nextIndex = (appHistory.current?.index ?? -2) - 1;
const previous = nextIndex < startingLength ? undefined : appHistory.entries()[nextIndex];
// console.log({ previous });
if (previous?.url === "/product-listing") {
// console.log("Back");
const { finished } = appHistory.back();
finishedClickNavigation.resolve(finished);
await finished;
}
else {
// console.log("Navigate replace");
// If the user arrived here by typing the URL directly:
const { finished } = appHistory.navigate("/product-listing", { replace: true });
finishedClickNavigation.resolve(finished);
await finished;
}
});
ok(appHistory.entries().length === startingLength);
await backButtonEl.dispatchEvent({
type: "click"
});
await finishedClickNavigation.promise;
finishedClickNavigation = deferred();
ok(pathname(appHistory.current?.url) === "/product-listing");
if (isWindowAppHistory(appHistory)) {
// We would have replaced the initial!
ok(appHistory.entries().length === startingLength);
}
else {
// Else for non spec we will have our first navigation
ok(appHistory.entries().length === 1);
}
const { finished } = await appHistory.navigate("/product-listing/product");
await finished;
ok(pathname(appHistory.current?.url) === "/product-listing/product");
if (isWindowAppHistory(appHistory)) {
// We should have navigated here, so increase the count
ok(appHistory.entries().length === startingLength + 1);
}
else {
ok(appHistory.entries().length === 2);
}
await backButtonEl.dispatchEvent({
type: "click"
});
await finishedClickNavigation.promise;
finishedClickNavigation = deferred();
// console.log(appHistory.entries());
ok(pathname(appHistory.current?.url) === "/product-listing");
// We should have gone back here, length should have stayed the same
if (isWindowAppHistory(appHistory)) {
ok(appHistory.entries().length === startingLength + 1);
}
else {
ok(appHistory.entries().length === 2);
}
}
export async function performanceExample(appHistory) {
// const performance = await getPerformance();
//
// for (const entry of performance?.getEntriesByType("same-document-navigation")) {
// console.log(`It took ${entry.duration} ms to navigate to the URL ${entry.name}`);
// }
}
export async function currentReloadExample(appHistory) {
await appHistory.navigate('/').finished;
await appHistory.reload({ state: { ...appHistory.current?.getState(), test: 3 } }).finished;
ok(appHistory.current?.getState().test === 3);
}
export async function currentChangeExample(appHistory) {
let changedEvent;
appHistory.addEventListener("currentchange", event => {
changedEvent = event;
});
if (!isWindowAppHistory(appHistory)) {
ok(!appHistory.current);
}
else {
ok(appHistory.current);
}
await appHistory.navigate('/').finished;
assert(changedEvent);
ok(changedEvent.navigationType);
if (!isWindowAppHistory(appHistory)) {
ok(!changedEvent.from);
}
else {
ok(changedEvent.from);
}
const initial = appHistory.current;
assert(initial);
await appHistory.navigate('/1').finished;
assert(changedEvent);
ok(changedEvent.navigationType);
assert(changedEvent.from);
ok(changedEvent.from.id === initial.id);
}
export async function homepageGoToExample(appHistory) {
const homeButton = new EventTarget();
await appHistory.navigate('/home').finished;
const homepageKey = appHistory.current?.key;
assert(homepageKey);
homeButton.addEventListener("click", async () => {
await appHistory.goTo(homepageKey).finished;
});
await appHistory.navigate('/other').finished;
ok(pathname(appHistory.current?.url) === '/other');
await homeButton.dispatchEvent({
type: "click"
});
ok(pathname(appHistory.current?.url) === '/home');
}
export async function toggleExample(appHistory) {
await appHistory.navigate('/').finished;
const detailsElement = new EventTarget();
detailsElement.addEventListener("toggle", async () => {
const state = appHistory.current?.getState();
await appHistory.updateCurrent({
state: {
...state,
detailsOpen: detailsElement.open ?? !state?.detailsOpen
}
});
});
ok(!appHistory.current?.getState()?.detailsOpen);
await detailsElement.dispatchEvent({
type: "toggle"
});
ok(appHistory.current?.getState().detailsOpen === true);
await detailsElement.dispatchEvent({
type: "toggle"
});
ok(appHistory.current?.getState().detailsOpen === false);
}
export async function perEntryEventsExample(appHistory) {
if (isWindowAppHistory(appHistory))
return; // navigatefrom + navigateto not yet available
async function showPhoto(photoId) {
const { committed, finished } = appHistory.navigate(`/photos/${photoId}`, { state: {} });
// In our app, the `navigate` handler will take care of actually showing the photo and updating the content area.
const entry = await committed;
const updateCurrentEntryFinished = deferred();
// When we navigate away from this photo, save any changes the user made.
entry.addEventListener("navigatefrom", async () => {
console.log("navigatefrom");
const result = appHistory.updateCurrent({
state: {
dateTaken: new Date().toISOString(),
caption: `Photo taken on the date ${new Date().toDateString()}`
}
});
// Just ensure committed before we move on
// We know that this will be applied at a minimum
updateCurrentEntryFinished.resolve(result);
}, { once: true });
let navigateBackToState;
// If we ever navigate back to this photo, e.g. using the browser back button or
// appHistory.goTo(), restore the input values.
entry.addEventListener("navigateto", () => {
const next = appHistory.current?.getState();
if (next) {
navigateBackToState = next;
}
});
await finished;
// Trigger navigatefrom
await appHistory.navigate("/").finished;
assert(updateCurrentEntryFinished);
await updateCurrentEntryFinished.promise;
ok(updateCurrentEntryFinished);
await updateCurrentEntryFinished;
// Trigger naviagateto
await appHistory.goTo(entry.key).finished;
assert(appHistory.current?.key === entry.key);
const finalState = appHistory.current?.getState();
console.log(finalState);
console.log({ finalState, navigateBackToState, current: appHistory.current });
assert(navigateBackToState);
ok(navigateBackToState.dateTaken);
ok(navigateBackToState.caption);
assert(finalState);
assert(navigateBackToState);
ok(finalState.dateTaken === navigateBackToState.dateTaken);
ok(finalState.caption === navigateBackToState.caption);
}
// console.log("-----");
await showPhoto('1');
}
export async function disposeExample(appHistory) {
await appHistory.navigate("/").finished;
const startingKey = appHistory.current?.key;
assert(startingKey);
const values = [];
assert(values);
console.log(JSON.stringify({ 0: values }));
const { committed: entry1Committed, finished: entry1Finished } = appHistory.navigate("/1");
const entry1 = await entry1Committed;
entry1.addEventListener("dispose", () => values.push(1));
const entry1Disposed = new Promise(resolve => entry1.addEventListener("dispose", resolve, { once: true }));
await entry1Finished;
const { committed: entry2Committed, finished: entry2Finished } = appHistory.navigate("/2");
const entry2 = await entry2Committed;
const entry2Disposed = new Promise(resolve => entry2.addEventListener("dispose", resolve, { once: true }));
entry2.addEventListener("dispose", () => values.push(2));
await entry2Finished;
const { committed: entry3Committed, finished: entry3Finished } = await appHistory.navigate("/3");
const entry3 = await entry3Committed;
const entry3Disposed = new Promise(resolve => entry3.addEventListener("dispose", resolve, { once: true }));
entry3.addEventListener("dispose", () => values.push(3));
await entry3Finished;
await appHistory.goTo(startingKey).finished;
await appHistory.navigate("/1-b").finished;
await Promise.all([entry1Disposed, entry2Disposed, entry3Disposed]);
// console.log(JSON.stringify({ 3: values, entries: appHistory.entries() }));
ok(values.includes(1));
ok(values.includes(2));
ok(values.includes(3));
console.log(JSON.stringify(values));
}
export async function currentChangeMonitoringExample(appHistory) {
appHistory.addEventListener("currentchange", () => {
appHistory.current?.addEventListener("dispose", genericDisposeHandler);
});
let disposedCount = 0;
function genericDisposeHandler() {
disposedCount += 1;
}
await appHistory.navigate("/").finished;
ok(!disposedCount);
await appHistory.navigate("/1").finished;
ok(!disposedCount);
await appHistory.navigate("/2").finished;
ok(!disposedCount);
// Dispose first
await appHistory.navigate('/', { replace: true }).finished;
// Dispose Second
await appHistory.navigate('/', { replace: true }).finished;
// Should be back at start
ok(disposedCount === 2);
}
export async function rollbackExample(appHistory) {
if (isWindowAppHistory(appHistory))
return; // Does not work as expected
const expectedError = `Error.${Math.random()}`;
const toasts = [];
function showErrorToast(message) {
toasts.push(message);
}
// rollback is automatically triggered on error
// let navigateErrorTransitionFinished = deferred<unknown>(),
// navigateErrorTransitionCommitted = deferred<unknown>();
let navigateError = deferred();
appHistory.addEventListener("navigateerror", e => {
navigateError.resolve();
showErrorToast(`Could not load: ${e.message}`);
});
// Should be successful, no failing navigator yet
await appHistory.navigate("/").finished;
ok(!toasts.length);
const expectedRollbackState = await appHistory.navigate(`/${Math.random()}`).finished;
ok(!toasts.length);
// Reject after committed using currentchange, or before committed using navigate
appHistory.addEventListener("navigate", (event) => {
event.transitionWhile(Promise.reject(new Error(expectedError)));
}, { once: true });
// This should fail
const errorUrl = `/thisWillError/${Math.random()}`;
const { committed, finished } = appHistory.navigate(errorUrl);
// rollback is automatically triggered on error
// await navigateErrorTransitionCommitted.promise;
// await navigateErrorTransitionFinished.promise;
await navigateError.promise;
const [committedEntry, finishedError] = await Promise.all([
committed,
finished.catch((error) => error)
]);
// console.log({
// committedEntry,
// finishedError
// });
assert(committedEntry);
assert(finishedError);
assert(finishedError instanceof Error);
assert(finishedError.message === expectedError);
ok(appHistory.current);
console.log({ current: appHistory.current.url, expectedRollbackState: expectedRollbackState.url, toasts });
ok(pathname(appHistory.current?.url) === pathname(expectedRollbackState.url));
console.log({ toasts });
ok(toasts.length);
ok(toasts[0].includes(expectedError));
}
export async function singlePageAppRedirectsAndGuards(appHistory) {
if (isWindowAppHistory(appHistory)) {
// TODO WARN investigate
return;
}
function determineAction(destination) {
const { pathname, searchParams } = new URL(destination.url, "https://example.com");
const destinationState = {
...destination.getState()
};
searchParams.forEach(([key, value]) => destinationState[key] = destinationState[key] ?? value);
const type = {
"/redirect": "redirect",
"/disallow": "disallow"
}[pathname] ?? "pass";
console.log({ pathname, type });
// console.log({ type, destination });
return {
type,
destinationURL: (type === "redirect" && searchParams.get("target")) || destination.url,
destinationState,
disallowReason: type === "disallow" ? (searchParams.get("reason") ?? undefined) : undefined
};
}
let allowCount = 0;
let disallowCount = 0;
// TODO replace with transition usage
let redirectFinished = undefined;
const seen = new WeakSet();
appHistory.addEventListener("navigate", e => {
if (seen.has(e) || seen.has(e.transitionWhile)) {
console.log(e, seen.has(e), seen.has(e.transitionWhile));
// throw new Error("Seen event multiple times");
}
console.log("Adding");
seen.add(e);
seen.add(e.transitionWhile);
e.transitionWhile((async () => {
const result = determineAction(e.destination);
if (result.type === "redirect") {
if (isWindowAppHistory(appHistory)) {
e.preventDefault();
}
console.log("Redirecting");
redirectFinished = appHistory.transition?.rollback().finished
.then(() => appHistory.navigate(result.destinationURL, { state: result.destinationState }).finished);
// await appHistory.transition?.rollback().finished;
// await appHistory.navigate(result.destinationURL, { state: result.destinationState }).finished;
}
else if (result.type === "disallow") {
disallowCount += 1;
throw new Error(result.disallowReason);
}
else {
// ...
// Allow the transition
allowCount += 1;
return;
}
})());
});
if (!isWindowAppHistory(appHistory)) {
ok(!appHistory.current);
}
await appHistory.navigate("/").finished;
ok(appHistory.current);
ok(pathname(appHistory.current?.url) === "/");
ok(allowCount === 1);
const redirectTargetUrl = `/redirected/${Math.random()}`;
const targetUrl = new URL("/redirect", "https://example.com");
targetUrl.searchParams.set("target", redirectTargetUrl);
const { committed: redirectCommitted, finished: redirectFinishedErrored } = appHistory.navigate(targetUrl.toString());
redirectFinishedErrored.catch(error => void error);
await redirectCommitted.catch(error => void error);
const redirectError = await redirectFinishedErrored.catch(error => error);
// console.log({ redirectError });
assert(redirectError);
assert(redirectError instanceof Error);
// TODO pending transition here would allow us to see the new navigation changes
// const pendingTransition = appHistory.transition;
// ok(pendingTransition);
// await pendingTransition;
assert(redirectFinished);
await redirectFinished;
ok(pathname(appHistory.current?.url) === redirectTargetUrl);
ok(allowCount === 2);
const expectedInitialError = `${Math.random()}`;
const errorTargetUrl = new URL("/disallow", "https://example.com");
errorTargetUrl.searchParams.set("reason", expectedInitialError);
ok(disallowCount === 0);
const { committed: disallowCommitted, finished: disallowFinished } = appHistory.navigate(errorTargetUrl.toString());
await disallowCommitted.catch(error => error);
const initialError = await disallowFinished.catch(error => error);
assert(initialError);
assert(initialError instanceof Error);
// console.log(initialError);
assert(initialError.message === expectedInitialError);
ok(allowCount === 2);
ok(disallowCount === 1);
}
export async function navigationExamples(appHistory) {
const url = `/${Math.random()}`;
const stateSymbol = Symbol();
const infoSymbol = Symbol();
const state = { [stateSymbol]: true };
const info = { [infoSymbol]: true };
// Performs a navigation to the given URL, but replace the current history entry
// instead of pushing a new one.
// (equivalent to `location.replace(url)`)
await appHistory.navigate(url, { replace: true }).finished;
// Replace the URL and state at the same time.
await appHistory.navigate(url, { replace: true, state }).finished;
// You can still pass along info:
await appHistory.navigate(url, { replace: true, state, info }).finished;
// Just like location.reload().
await appHistory.reload().finished;
// Leave the state as-is, but pass some info.
await appHistory.reload({ info }).finished;
// Overwrite the state with a new value.
await appHistory.reload({ state, info }).finished;
}
export async function usingInfoExample(appHistory) {
const photoGallery = new EventTarget();
const document = new EventTarget();
const photos = new Map(Array.from({ length: 10 })
.map((unused, index) => [{}, `/photo/${index}`]));
const photoTargets = new Map([...photos.entries()].map(([key, value]) => [value, key]));
const photoUrls = [...photos.values()];
function getPhotoSiblings() {
const index = photoUrls.indexOf(pathname(appHistory.current?.url ?? "/unknown"));
if (index === -1)
return [];
return [photoUrls[index - 1], photoUrls[index + 1]];
}
function hasPreviousPhoto() {
return !!getPreviousPhotoURL();
}
function hasNextPhoto() {
return !!getNextPhotoURL();
}
function getPreviousPhotoURL() {
return getPhotoSiblings()[0];
}
function getNextPhotoURL() {
return getPhotoSiblings()[1];
}
function getPhotoURL(target) {
if (!target || typeof target !== "object")
throw new Error("Expected object target");
return photos.get(target);
}
function isPhotoNavigation(event) {
function isLike(event) {
return !!event;
}
return (isLike(event) &&
typeof event.info === "object" &&
typeof event.info.via === "string" &&
typeof event.info.thumbnail === "object");
}
document.addEventListener("keydown", async (event) => {
if (event.key === "ArrowLeft" && hasPreviousPhoto()) {
const url = getPreviousPhotoURL();
await appHistory.navigate(url, { info: { via: "go-left", thumbnail: photoTargets.get(url) } }).finished;
}
if (event.key === "ArrowRight" && hasNextPhoto()) {
const url = getNextPhotoURL();
await appHistory.navigate(url, { info: { via: "go-right", thumbnail: photoTargets.get(url) } }).finished;
}
});
photoGallery.addEventListener("click", async ({ target }) => {
const url = getPhotoURL(target);
if (!url)
return;
await appHistory.navigate(url, { info: { via: "gallery", thumbnail: target } }).finished;
});
let lefts = 0, rights = 0, zoomies = [], loaded = [];
function animateLeft() {
lefts += 1;
}
function animateRight() {
rights += 1;
}
function animateZoomFromThumbnail(thumbnail) {
zoomies.push(thumbnail);
}
async function loadPhoto(url) {
await new Promise(queueMicrotask);
loaded.push(pathname(url));
}
appHistory.addEventListener("navigate", e => {
e.transitionWhile((async () => {
if (isPhotoNavigation(e)) {
const { thumbnail, via } = e.info;
switch (via) {
case "go-left": {
await animateLeft();
break;
}
case "go-right": {
await animateRight();
break;
}
case "gallery": {
await animateZoomFromThumbnail(thumbnail);
break;
}
}
}
await loadPhoto(e.destination.url);
})());
});
const middleIndex = Math.round(photoUrls.length / 2);
assert(middleIndex === 5);
// console.log({
// via: "navigation",
// thumbnail: photoTargets.get(photoUrls[middleIndex])
// });
// We have not yet given a starting point
ok(!hasPreviousPhoto());
ok(!hasNextPhoto());
await appHistory.navigate(photoUrls[middleIndex], {
info: {
via: "navigation",
thumbnail: photoTargets.get(photoUrls[middleIndex])
}
}).finished;
// We should now have photos
ok(hasPreviousPhoto());
ok(hasNextPhoto());
ok(!lefts);
ok(!rights);
// console.log({ loaded });
ok(loaded.includes(photoUrls[middleIndex]));
await document.dispatchEvent({
type: "keydown",
key: "ArrowLeft"
});
ok(lefts === 1);
ok(loaded.includes(photoUrls[middleIndex - 1]));
await document.dispatchEvent({
type: "keydown",
key: "ArrowLeft"
});
ok(lefts === 2);
ok(loaded.includes(photoUrls[middleIndex - 2]));
await document.dispatchEvent({
type: "keydown",
key: "ArrowLeft"
});
ok(lefts === 3);
ok(loaded.includes(photoUrls[middleIndex - 3]));
await document.dispatchEvent({
type: "keydown",
key: "ArrowLeft"
});
ok(lefts === 4);
ok(loaded.includes(photoUrls[middleIndex - 4]));
await document.dispatchEvent({
type: "keydown",
key: "ArrowLeft"
});
ok(lefts === 5);
ok(loaded.includes(photoUrls[middleIndex - 5]));
await document.dispatchEvent({
type: "keydown",
key: "ArrowLeft"
});
// Should stay the same
ok(lefts === 5);
ok(loaded.includes(photoUrls[middleIndex - 5]));
// Reset to middle, and go the other way
await appHistory.navigate(photoUrls[middleIndex], {
info: {
via: "navigation",
thumbnail: photoTargets.get(photoUrls[middleIndex])
}
}).finished;
ok(!rights);
await document.dispatchEvent({
type: "keydown",
key: "ArrowRight"
});
ok(rights === 1);
ok(loaded.includes(photoUrls[middleIndex + 1]));
await document.dispatchEvent({
type: "keydown",
key: "ArrowRight"
});
ok(rights === 2);
ok(loaded.includes(photoUrls[middleIndex + 2]));
await document.dispatchEvent({
type: "keydown",
key: "ArrowRight"
});
ok(rights === 3);
ok(loaded.includes(photoUrls[middleIndex + 3]));
await document.dispatchEvent({
type: "keydown",
key: "ArrowRight"
});
ok(rights === 4);
ok(loaded.includes(photoUrls[middleIndex + 4]));
await document.dispatchEvent({
type: "keydown",
key: "ArrowRight"
});
// Should stay the same
ok(rights === 4);
ok(loaded.includes(photoUrls[middleIndex + 4]));
ok(!zoomies.length);
await photoGallery.dispatchEvent({
type: "click",
target: photoTargets.get(pathname(appHistory.current?.url ?? "/unknown"))
});
ok(zoomies.length);
ok(photoTargets.get(pathname(appHistory.current?.url ?? "/unknown")));
ok(zoomies.includes(photoTargets.get(pathname(appHistory.current?.url ?? "/unknown")) ?? {}));
}
export async function nextPreviousButtons(appHistory) {
const appState = {
currentPhoto: 0,
totalPhotos: 10
};
const photos = {};
const next = new EventTarget();
const previous = new EventTarget();
const permalink = new EventTarget();
const currentPhoto = new EventTarget();
next.addEventListener("click", async () => {
const nextPhotoInHistory = photoNumberFromURL(appHistory.entries()[(appHistory.current?.index ?? -2) + 1]?.url);
if (nextPhotoInHistory === appState.currentPhoto + 1) {
await appHistory.forward().finished;
}
else {
await appHistory.navigate(`/photos/${appState.currentPhoto + 1}`).finished;
}
});
previous.addEventListener("click", async () => {
const prevPhotoInHistory = photoNumberFromURL(appHistory.entries()[(appHistory.current?.index ?? -2) - 1]?.url);
// console.log(prevPhotoInHistory)
// console.log({ prevPhotoInHistory, matching: appState.currentPhoto - 1, nav: `/photos/${appState.currentPhoto - 1}` });
if (prevPhotoInHistory === appState.currentPhoto - 1) {
// console.log("BACK!");
await appHistory.back().finished;
}
else {
// console.log({ navigate: `/photos/${appState.currentPhoto - 1}` })
await appHistory.navigate(`/photos/${appState.currentPhoto - 1}`).finished;
}
});
const photosPrefix = "/raw-photos";
const contentPhotosPrefix = `/photo/content${photosPrefix}`;
const localCache = new Map();
let fetchCount = 0;
const fetchHandler = (event) => {
const { pathname } = new URL(event.request.url, "https://example.com");
const [, photoNumber] = pathname.match(/^\/raw-photos\/(\d+)\.[a-z]+$/i) ?? [];
if (!photoNumber)
return;
fetchCount += 1;
photos[photoNumber] = photos[photoNumber] || `https://example.com${contentPhotosPrefix}/${photoNumber}`;
return event.respondWith(new Response(photos[photoNumber], {
status: 200
}));
};
let navigateFinished;
addEventListener("fetch", fetchHandler);
appHistory.addEventListener("navigate", event => {
const photoNumberMaybe = photoNumberFromURL(event.destination.url);
console.log({ canTransition: event.canTransition, photoNumberMaybe });
if (!(typeof photoNumberMaybe === "number" && event.canTransition))
return;
const photoNumber = photoNumberMaybe;
event.transitionWhile(navigateFinished = handler());
async function handler() {
console.log("transitioning for ", { photoNumber });
// Synchronously update app state and next/previous/permalink UI:
appState.currentPhoto = photoNumber;
previous.disabled = appState.currentPhoto === 0;
next.disabled = appState.currentPhoto === appState.totalPhotos - 1;
permalink.textContent = event.destination.url;
const existingSrc = event.destination.key && localCache.get(event.destination.key);
console.log({ photoNumber, existingSrc, key: event.destination.key });
if (typeof existingSrc === "string") {
currentPhoto.src = existingSrc;
return;
}
// Asynchronously update the photo, passing along the signal so that
// it all gets aborted if another navigation interrupts us:
const response = await fetch(`${photosPrefix}/${photoNumber}.jpg`, { signal: event.signal });
// const blob = await response.blob();
// currentPhoto.src = URL.createObjectURL(blob);
const src = await response.text();
currentPhoto.src = src;
if (event.destination.key) {
localCache.set(event.destination.key, src);
}
}
});
// Is not a photo should not load
await appHistory.navigate("/").finished;
ok(!currentPhoto.src);
await appHistory.navigate("/photos/0").finished;
ok(navigateFinished);
await navigateFinished;
console.log("Current photo");
console.log(JSON.stringify({ src: currentPhoto.src }));
assert(currentPhoto.src);
ok(new URL(currentPhoto.src).pathname === `${contentPhotosPrefix}/0`);
ok(!next.disabled);
ok(previous.disabled);
await appHistory.navigate("/photos/1").finished;
ok(navigateFinished);
await navigateFinished;
console.log("Current photo");
console.log(JSON.stringify({ src: currentPhoto.src }));
assert(currentPhoto.src);
ok(new URL(currentPhoto.src).pathname === `${contentPhotosPrefix}/1`);
ok(!next.disabled);
ok(!previous.disabled);
// log: Updated window pathname to /photos/2
await next.dispatchEvent({
type: "click"
});
// Utilised navigate
assert(currentPhoto.src);
ok(new URL(currentPhoto.src).pathname === `${contentPhotosPrefix}/2`);
await appHistory.navigate("/photos/9").finished;
assert(currentPhoto.src);
ok(new URL(currentPhoto.src).pathname === `${contentPhotosPrefix}/9`);
ok(next.disabled);
ok(!previous.disabled);
// log: Updated window pathname to /photos/8
await previous.dispatchEvent({
type: "click"
});
assert(currentPhoto.src);
// console.log(currentPhoto);
// Utilised navigate
ok(new URL(currentPhoto.src).pathname === `${contentPhotosPrefix}/8`);
// log: Updated window pathname to /photos/7
await previous.dispatchEvent({
type: "click"
});
assert(currentPhoto.src);
// Utilised navigate
ok(new URL(currentPhoto.src).pathname === `${contentPhotosPrefix}/7`);
// Updated window pathname to /photos/8
await next.dispatchEvent({
type: "click"
});
// Updated window pathname to /photos/9
await next.dispatchEvent({
type: "click"
});
// Utilised navigation!
assert(currentPhoto.src);
ok(new URL(currentPhoto.src).pathname === `${contentPhotosPrefix}/9`);
// This should be 8
const finalFetchCount = fetchCount;
assert(finalFetchCount === 8);
// log: Updated window pathname to /photos/8
await previous.dispatchEvent({
type: "click"
});
// Utilised back!
assert(currentPhoto.src);
ok(new URL(currentPhoto.src).pathname === `${contentPhotosPrefix}/8`);
// log: Updated window pathname to /photos/7
await previous.dispatchEvent({
type: "click"
});
// Utilised back!
assert(currentPhoto.src);
ok(new URL(currentPhoto.src).pathname === `${contentPhotosPrefix}/7`);
// console.log({ finalFetchCount, fetchCount });
if (!isWindowAppHistory(appHistory)) {
ok(finalFetchCount === fetchCount);
}
// log: Updated window pathname to /photos/8
await next.dispatchEvent({
type: "click"
});
// Utilised forward!
assert(currentPhoto.src);
ok(new URL(currentPhoto.src).pathname === `${contentPhotosPrefix}/8`);
if (!isWindowAppHistory(appHistory)) {
ok(finalFetchCount === fetchCount);
}
// log: Updated window pathname to /photos/9
await next.dispatchEvent({
type: "click"
});
// Utilised forward!
assert(currentPhoto.src);
ok(new URL(currentPhoto.src).pathname === `${contentPhotosPrefix}/9`);
if (!isWindowAppHistory(appHistory)) {
ok(finalFetchCount === fetchCount);
}
removeEventListener("fetch", fetchHandler);
function photoNumberFromURL(url) {
console.log(JSON.stringify({ photoNumberFromURL: url, path: pathname(url) }));
if (!url) {
return undefined;
}
const [, photoNumber] = /\/photos\/(\d+)/.exec(pathname(url)) ?? [];
console.log({ url, photoNumber });
if (photoNumber) {
return +photoNumber;
}
return undefined;
}
}
function pathname(url) {
return new URL(url ?? "/", "https://example.com").pathname;
}
//# sourceMappingURL=readme-detailed.js.map