Transition only between pages where both have ViewTransitions enabled (#8441)
* added e2e test regarding loss of router * only navigate to pages from which we can navigate back * location does not change before deferred pushState * initialize history state * test cases adapted to new semantics (only traverse to pages w/ ViewTransigs) * type URL instead of Location * + changeset
This commit is contained in:
parent
7ea32c7fbf
commit
f66053a1ea
5 changed files with 106 additions and 60 deletions
5
.changeset/curvy-dolls-thank.md
Normal file
5
.changeset/curvy-dolls-thank.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'astro': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Only transition between pages where both have ViewTransitions enabled
|
|
@ -19,7 +19,9 @@ const { fallback = 'animate' } = Astro.props as Props;
|
||||||
};
|
};
|
||||||
type Events = 'astro:page-load' | 'astro:after-swap';
|
type Events = 'astro:page-load' | 'astro:after-swap';
|
||||||
|
|
||||||
const persistState = (state: State) => history.replaceState(state, '');
|
// only update history entries that are managed by us
|
||||||
|
// leave other entries alone and do not accidently add state.
|
||||||
|
const persistState = (state: State) => history.state && history.replaceState(state, '');
|
||||||
const supportsViewTransitions = !!document.startViewTransition;
|
const supportsViewTransitions = !!document.startViewTransition;
|
||||||
const transitionEnabledOnThisPage = () =>
|
const transitionEnabledOnThisPage = () =>
|
||||||
!!document.querySelector('[name="astro-view-transitions-enabled"]');
|
!!document.querySelector('[name="astro-view-transitions-enabled"]');
|
||||||
|
@ -32,11 +34,13 @@ const { fallback = 'animate' } = Astro.props as Props;
|
||||||
// can use that to determine popstate if going forward or back.
|
// can use that to determine popstate if going forward or back.
|
||||||
let currentHistoryIndex = 0;
|
let currentHistoryIndex = 0;
|
||||||
if (history.state) {
|
if (history.state) {
|
||||||
// we reloaded a page with history state (e.g. back button or browser reload)
|
// we reloaded a page with history state
|
||||||
|
// (e.g. history navigation from non-transition page or browser reload)
|
||||||
currentHistoryIndex = history.state.index;
|
currentHistoryIndex = history.state.index;
|
||||||
scrollTo({ left: 0, top: history.state.scrollY });
|
scrollTo({ left: 0, top: history.state.scrollY });
|
||||||
|
} else if (transitionEnabledOnThisPage()) {
|
||||||
|
history.replaceState({index: currentHistoryIndex, scrollY}, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const throttle = (cb: (...args: any[]) => any, delay: number) => {
|
const throttle = (cb: (...args: any[]) => any, delay: number) => {
|
||||||
let wait = false;
|
let wait = false;
|
||||||
// During the waiting time additional events are lost.
|
// During the waiting time additional events are lost.
|
||||||
|
@ -109,9 +113,7 @@ const { fallback = 'animate' } = Astro.props as Props;
|
||||||
|
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
|
|
||||||
async function updateDOM(html: string, state?: State, fallback?: Fallback) {
|
async function updateDOM(doc: Document, loc: URL, state?: State, fallback?: Fallback) {
|
||||||
const doc = parser.parseFromString(html, 'text/html');
|
|
||||||
|
|
||||||
// Check for a head element that should persist, either because it has the data
|
// Check for a head element that should persist, either because it has the data
|
||||||
// attribute or is a link el.
|
// attribute or is a link el.
|
||||||
const persistedHeadElement = (el: Element): Element | null => {
|
const persistedHeadElement = (el: Element): Element | null => {
|
||||||
|
@ -189,19 +191,21 @@ const { fallback = 'animate' } = Astro.props as Props;
|
||||||
// Chromium based browsers (Chrome, Edge, Opera, ...)
|
// Chromium based browsers (Chrome, Edge, Opera, ...)
|
||||||
scrollTo({ left: 0, top: 0, behavior: 'instant' });
|
scrollTo({ left: 0, top: 0, behavior: 'instant' });
|
||||||
|
|
||||||
if (state?.scrollY === 0 && location.hash) {
|
let initialScrollY = 0;
|
||||||
const id = decodeURIComponent(location.hash.slice(1));
|
if (!state && loc.hash) {
|
||||||
|
const id = decodeURIComponent(loc.hash.slice(1));
|
||||||
const elem = document.getElementById(id);
|
const elem = document.getElementById(id);
|
||||||
// prefer scrollIntoView() over scrollTo() because it takes scroll-padding into account
|
// prefer scrollIntoView() over scrollTo() because it takes scroll-padding into account
|
||||||
if (elem) {
|
elem && (initialScrollY = elem.offsetTop) && elem.scrollIntoView();
|
||||||
state.scrollY = elem.offsetTop;
|
|
||||||
persistState(state); // first guess, later updated by scroll handler
|
|
||||||
elem.scrollIntoView(); // for Firefox, this should better be {behavior: 'instant'}
|
|
||||||
}
|
|
||||||
} else if (state && state.scrollY !== 0) {
|
} else if (state && state.scrollY !== 0) {
|
||||||
scrollTo(0, state.scrollY); // usings default scrollBehavior
|
scrollTo(0, state.scrollY); // usings default scrollBehavior
|
||||||
}
|
}
|
||||||
|
!state &&
|
||||||
|
history.pushState(
|
||||||
|
{ index: ++currentHistoryIndex, scrollY: initialScrollY },
|
||||||
|
'',
|
||||||
|
loc.href
|
||||||
|
);
|
||||||
triggerEvent('astro:after-swap');
|
triggerEvent('astro:after-swap');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -247,19 +251,26 @@ const { fallback = 'animate' } = Astro.props as Props;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function navigate(dir: Direction, href: string, state?: State) {
|
async function navigate(dir: Direction, loc: URL, state?: State) {
|
||||||
let finished: Promise<void>;
|
let finished: Promise<void>;
|
||||||
|
const href=loc.href;
|
||||||
const { html, ok } = await getHTML(href);
|
const { html, ok } = await getHTML(href);
|
||||||
// If there is a problem fetching the new page, just do an MPA navigation to it.
|
// If there is a problem fetching the new page, just do an MPA navigation to it.
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
location.href = href;
|
location.href = href;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const doc = parser.parseFromString(html, 'text/html');
|
||||||
|
if (!doc.querySelector('[name="astro-view-transitions-enabled"]')) {
|
||||||
|
location.href = href;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
document.documentElement.dataset.astroTransition = dir;
|
document.documentElement.dataset.astroTransition = dir;
|
||||||
if (supportsViewTransitions) {
|
if (supportsViewTransitions) {
|
||||||
finished = document.startViewTransition(() => updateDOM(html, state)).finished;
|
finished = document.startViewTransition(() => updateDOM(doc, loc, state)).finished;
|
||||||
} else {
|
} else {
|
||||||
finished = updateDOM(html, state, getFallback());
|
finished = updateDOM(doc, loc, state, getFallback());
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await finished;
|
await finished;
|
||||||
|
@ -311,11 +322,11 @@ const { fallback = 'animate' } = Astro.props as Props;
|
||||||
ev.shiftKey || // new window
|
ev.shiftKey || // new window
|
||||||
ev.defaultPrevented ||
|
ev.defaultPrevented ||
|
||||||
!transitionEnabledOnThisPage()
|
!transitionEnabledOnThisPage()
|
||||||
)
|
) {
|
||||||
// No page transitions in these cases,
|
// No page transitions in these cases,
|
||||||
// Let the browser standard action handle this
|
// Let the browser standard action handle this
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
// We do not need to handle same page links because there are no page transitions
|
// We do not need to handle same page links because there are no page transitions
|
||||||
// Same page means same path and same query params (but different hash)
|
// Same page means same path and same query params (but different hash)
|
||||||
if (location.pathname === link.pathname && location.search === link.search) {
|
if (location.pathname === link.pathname && location.search === link.search) {
|
||||||
|
@ -341,10 +352,8 @@ const { fallback = 'animate' } = Astro.props as Props;
|
||||||
|
|
||||||
// these are the cases we will handle: same origin, different page
|
// these are the cases we will handle: same origin, different page
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
navigate('forward', link.href, { index: ++currentHistoryIndex, scrollY: 0 });
|
persistState({ index: currentHistoryIndex, scrollY });
|
||||||
const newState: State = { index: currentHistoryIndex, scrollY };
|
navigate('forward', new URL(link.href));
|
||||||
persistState({ index: currentHistoryIndex - 1, scrollY });
|
|
||||||
history.pushState(newState, '', link.href);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
addEventListener('popstate', (ev) => {
|
addEventListener('popstate', (ev) => {
|
||||||
|
@ -374,11 +383,11 @@ const { fallback = 'animate' } = Astro.props as Props;
|
||||||
history.scrollRestoration = 'manual';
|
history.scrollRestoration = 'manual';
|
||||||
}
|
}
|
||||||
|
|
||||||
const state: State | undefined = history.state;
|
const state: State = history.state;
|
||||||
const nextIndex = state?.index ?? currentHistoryIndex + 1;
|
const nextIndex = state.index;
|
||||||
const direction: Direction = nextIndex > currentHistoryIndex ? 'forward' : 'back';
|
const direction: Direction = nextIndex > currentHistoryIndex ? 'forward' : 'back';
|
||||||
navigate(direction, location.href, state);
|
|
||||||
currentHistoryIndex = nextIndex;
|
currentHistoryIndex = nextIndex;
|
||||||
|
navigate(direction, new URL(location.href), state);
|
||||||
});
|
});
|
||||||
|
|
||||||
['mouseenter', 'touchstart', 'focus'].forEach((evName) => {
|
['mouseenter', 'touchstart', 'focus'].forEach((evName) => {
|
||||||
|
@ -403,12 +412,9 @@ const { fallback = 'animate' } = Astro.props as Props;
|
||||||
// There's not a good way to record scroll position before a back button.
|
// There's not a good way to record scroll position before a back button.
|
||||||
// So the way we do it is by listening to scrollend if supported, and if not continuously record the scroll position.
|
// So the way we do it is by listening to scrollend if supported, and if not continuously record the scroll position.
|
||||||
const updateState = () => {
|
const updateState = () => {
|
||||||
// only update history entries that are managed by us
|
|
||||||
// leave other entries alone and do not accidently add state.
|
|
||||||
if (history.state) {
|
|
||||||
persistState({ ...history.state, scrollY });
|
persistState({ ...history.state, scrollY });
|
||||||
}
|
}
|
||||||
};
|
|
||||||
if ('onscrollend' in window) addEventListener('scrollend', updateState);
|
if ('onscrollend' in window) addEventListener('scrollend', updateState);
|
||||||
else addEventListener('scroll', throttle(updateState, 300));
|
else addEventListener('scroll', throttle(updateState, 300));
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Page 5</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<p id="five">Page 5</p>
|
||||||
|
<a id="click-three" href="/two">go to 3</a>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -7,6 +7,8 @@
|
||||||
<p id="three">Page 3</p>
|
<p id="three">Page 3</p>
|
||||||
<a id="click-two" href="/two">go to 2</a>
|
<a id="click-two" href="/two">go to 2</a>
|
||||||
<br/>
|
<br/>
|
||||||
|
<a id="click-five" href="/five">go to 5</a>
|
||||||
|
<br/>
|
||||||
<a id="click-hash" href="#click-hash">hash target</a>
|
<a id="click-hash" href="#click-hash">hash target</a>
|
||||||
<p style="height: 150vh">Long paragraph</p>
|
<p style="height: 150vh">Long paragraph</p>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
@ -83,7 +83,7 @@ test.describe('View Transitions', () => {
|
||||||
expect(loads.length, 'There should only be 1 page load').toEqual(1);
|
expect(loads.length, 'There should only be 1 page load').toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Moving from a page without ViewTransitions triggers a full page navigation', async ({
|
test('Moving to a page without ViewTransitions triggers a full page navigation', async ({
|
||||||
page,
|
page,
|
||||||
astro,
|
astro,
|
||||||
}) => {
|
}) => {
|
||||||
|
@ -102,10 +102,6 @@ test.describe('View Transitions', () => {
|
||||||
p = page.locator('#three');
|
p = page.locator('#three');
|
||||||
await expect(p, 'should have content').toHaveText('Page 3');
|
await expect(p, 'should have content').toHaveText('Page 3');
|
||||||
|
|
||||||
await page.click('#click-two');
|
|
||||||
p = page.locator('#two');
|
|
||||||
await expect(p, 'should have content').toHaveText('Page 2');
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
loads.length,
|
loads.length,
|
||||||
'There should be 2 page loads. The original, then going from 3 to 2'
|
'There should be 2 page loads. The original, then going from 3 to 2'
|
||||||
|
@ -142,8 +138,8 @@ test.describe('View Transitions', () => {
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
loads.length,
|
loads.length,
|
||||||
'There should be only 1 page load. The original, but no additional loads for the hash change'
|
'There should be only 2 page loads (for page one & three), but no additional loads for the hash change'
|
||||||
).toEqual(1);
|
).toEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Moving from a page without ViewTransitions w/ back button', async ({ page, astro }) => {
|
test('Moving from a page without ViewTransitions w/ back button', async ({ page, astro }) => {
|
||||||
|
@ -501,12 +497,11 @@ test.describe('View Transitions', () => {
|
||||||
await page.click('#click-logo');
|
await page.click('#click-logo');
|
||||||
await downloadPromise;
|
await downloadPromise;
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
test('Scroll position is restored on back navigation from page w/o ViewTransitions', async ({
|
test('Scroll position is restored on back navigation from page w/o ViewTransitions', async ({
|
||||||
page,
|
page,
|
||||||
astro,
|
astro,
|
||||||
}) => {
|
}) => {
|
||||||
// Go to middle of long page
|
// Go to middle of long page
|
||||||
await page.goto(astro.resolveUrl('/long-page#click-external'));
|
await page.goto(astro.resolveUrl('/long-page#click-external'));
|
||||||
|
|
||||||
|
@ -522,4 +517,31 @@ test('Scroll position is restored on back navigation from page w/o ViewTransitio
|
||||||
await page.goBack();
|
await page.goBack();
|
||||||
locator = page.locator('#click-external');
|
locator = page.locator('#click-external');
|
||||||
await expect(locator).toBeInViewport();
|
await expect(locator).toBeInViewport();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Non transition navigation doesn't loose handlers", async ({ page, astro }) => {
|
||||||
|
// Go to page 1
|
||||||
|
await page.goto(astro.resolveUrl('/one'));
|
||||||
|
let p = page.locator('#one');
|
||||||
|
await expect(p, 'should have content').toHaveText('Page 1');
|
||||||
|
|
||||||
|
// go to page 3
|
||||||
|
await page.click('#click-three');
|
||||||
|
p = page.locator('#three');
|
||||||
|
await expect(p, 'should have content').toHaveText('Page 3');
|
||||||
|
|
||||||
|
// go to page 5
|
||||||
|
await page.click('#click-five');
|
||||||
|
p = page.locator('#five');
|
||||||
|
await expect(p, 'should have content').toHaveText('Page 5');
|
||||||
|
|
||||||
|
await page.goBack();
|
||||||
|
p = page.locator('#three');
|
||||||
|
await expect(p, 'should have content').toHaveText('Page 3');
|
||||||
|
|
||||||
|
await page.goBack();
|
||||||
|
p = page.locator('#one');
|
||||||
|
await expect(p, 'should have content').toHaveText('Page 1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue