kit icon indicating copy to clipboard operation
kit copied to clipboard

Add `onNavigate` lifecycle function, to enable view transitions

Open Rich-Harris opened this issue 2 years ago • 10 comments

Fixes #5689. This adds an onNavigate lifecycle function.

Presently, there are two navigation lifecycle functions — beforeNavigate and afterNavigate. beforeNavigate fires before any navigation, and gives you the ability to (for example) cancel it, while afterNavigate fires once the page has been updated following a navigation (or initial hydration).

Between them, most use cases are covered, but there is one major missing piece: between beforeNavigate and afterNavigate callbacks running, we need to load the code and data for the new route, and that can take a noticeable amount of time. Because of that, we can't use these functions to do some work immediately before a navigation takes effect.

Aside: @oodavid has collected some cases where beforeNavigate is insufficient, and this will require some design work in the near future. This PR shouldn't conflict with that work.

With View Transitions, this is a problem. When you call document.startViewTransition(callback), it takes a screenshot of the current page, and the page is frozen until the promise returned from callback resolves. This user experience is untenable.

onNavigate addresses this problem. Callbacks are called immediately before a navigation is applied; if they return functions, those functions are called once the DOM has updated.

Because the view transition callback isn't called synchronously, we in fact need to delay DOM updates until the screenshot has been taken. For that reason, onNavigate callbacks can return a promise that will delay the navigation from taking effect. This is a footgun — you could return a promise that resolves slowly or even fails to resolve, breaking your app — but in practice I don't expect this to be a problem as long as it's well-documented (and we could add dev-time checks to ensure that promises resolve quickly).

Here's an example — note the sliding nav indicator:

https://user-images.githubusercontent.com/1162160/230094152-64e3a4cc-d808-4d20-918a-b92819955485.mov

This was done by adding the following code to the root layout...

onNavigate(() => {
  if (!document.startViewTransition) return;

  return new Promise((fulfil) => {
    document.startViewTransition(() => new Promise(fulfil));
  });
});

...and the following CSS:

:root {
  /* disable document-level crossfade */
  view-transition-name: none;
}

li[aria-current='page']::before {
  view-transition-name: indicator; /* this name could be anything */
}

If we wanted to control the animation, we could do so like this:

:root::view-transition-group(indicator) {
  animation-duration: 1s;
}

(Note that we need to add the :root selector before the pseudo-element, otherwise Svelte will scope it to the component. We may want to omit ::view-transition-{old,new,group} from the CSS scoping logic in Svelte 4, so that idiomatic uses of the view transitions API are covered.)

I'll admit that this code...

return new Promise((fulfil) => {
  document.startViewTransition(() => new Promise(fulfil));
});

...is absolutely brain-breaking. Perhaps there's a nicer way to do this that I'm not seeing, but if not then we may eventually want to add some higher level utilities around this stuff.

cc @geoffrich who has been investigating the view transitions API more thoroughly than I have, and may have feedback/thoughts.

Please don't delete this checklist! Before submitting the PR, please make sure you do the following:

  • [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
  • [x] This message body should clearly illustrate what problems it solves.
  • [x] Ideally, include a test that fails without this PR but passes with it.

Tests

  • [x] Run the tests with pnpm test and lint the project with pnpm lint and pnpm check

Changesets

  • [x] If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running pnpm changeset and following the prompts. Changesets that add features should be minor and those that fix bugs should be patch. Please prefix changeset messages with feat:, fix:, or chore:.

Rich-Harris avatar Apr 05 '23 13:04 Rich-Harris

🦋 Changeset detected

Latest commit: fa6bc935907429d0467818f61f6c111705ee1986

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@sveltejs/kit Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

changeset-bot[bot] avatar Apr 05 '23 13:04 changeset-bot[bot]

Super excited for this - at first glance, this should fix a lot of the issues I had to work around in my SvelteKit + view transition demos. In particular, being able to return a promise will fix a lot of the race conditions I encountered that broke the transition.

Will try to make time tonight to experiment with this branch and my demos and see if anything is missing / doesn't work as expected.

geoffrich avatar Apr 05 '23 17:04 geoffrich

This is great! I was wondering if this new function could be used in a transition: directive like so:

<span transition:native={{duration: 300}}> Moving text </span>
function native(node, params){
  onNavigate(() => {
  // call startViewTransition
  });

  const uniqueId = "svelte-1234";
  node.style.setProperty("view-transition-name", uniqueId);
  
  // somehow generate the styles for animation-duration
  `:root::view-transition-group(`${uniqueId}`) {
  animation-duration: ${params.duration || 200}ms;
  }`
  
  return {} // nothing should be done by classic svelte transitions, we only set the View Transition API
}

Of course this would only make sense for SvelteKit, so it may live in a submodule like @sveltejs/kit/transition, but still leverages the same know-how for the user.

I don't really know how to generate styles in SSR from this function, would that be doable?

KoRnFactory avatar Apr 05 '23 21:04 KoRnFactory

Started trying this out with some of my demos and it's definitely a big improvement - I'm not seeing the race conditions I used to see, so preloading on hover works consistently. Though I admit, the suggested code snippet took a long time to wrap my brain around.

return new Promise((fulfil) => {
  document.startViewTransition(() => new Promise(fulfil));
});

I was able to better understand it by splitting it out like this:

onNavigate(async (navigation) => {
		if (!document.startViewTransition) {
			return;
		}

		// by awaiting this promise, we prevent SvelteKit from completing the navigation until the promise resolves
		// this promise is resolved with a function that will resolve the page transition promise
		const resolvePageTransition = await new Promise((fulfil) => {
			document.startViewTransition(async () => {
				await new Promise((res) => fulfil(res));
			});
		});

		// we return that function, which SvelteKit will automatically resolve in afterNavigate
		return () => {
			resolvePageTransition();
		};
	});

Not sure if this is gesturing at the API being too confusing or is just something we have to live with.

geoffrich avatar Apr 06 '23 23:04 geoffrich

Though I admit, the suggested code snippet took a long time to wrap my brain around.

It's really rough. If this is the canonical implementation of it, and this is the major use case of this feature, I'd consider abstracting it.

onNavigate(waitForTransition)

markjaquith avatar Apr 09 '23 05:04 markjaquith

Fwiw, I find the callback-returns-a-callback pattern a little opaque. It isn't always clear what the callback is for.

Instead, could the navigation have a promise that resolves when the navigation completes? Even better, that means it can reject if the navigation fails, and that information is useful to things like view transitions.

onNavigate(async (navigation) => {
  return new Promise((oldStateCaptureResolve) => {
    document.startViewTransition(() => {
      oldStateCaptureResolve();
      await navigation.complete;
    });
  });
});

I'm not against adding something like oldStateCaptured to the transition object, so the above could become:

onNavigate(async (navigation) => {
  const transition = document.startViewTransition(() => navigation.complete);
  await transition.oldStateCaptured;
});

Although you could do that with a helper today:

function startViewTransition(callback) {
  return new Promise((resolve) => {
    const transition = document.startViewTransition(() => {
      resolve(transition);
      return callback();
    });
  });
}

onNavigate(async (navigation) => {
  await startViewTransition(() => navigation.complete);
});

jakearchibald avatar Apr 11 '23 09:04 jakearchibald

I like the navigation.complete idea, I've implemented that. Could use some more tests, and we need to figure out what to do if errors occur inside lifecycle functions (per https://github.com/sveltejs/kit/pull/9605#discussion_r1159045478, though it also applies to beforeNavigate and afterNavigate), so I'll put this back in draft until that happens

Rich-Harris avatar Apr 19 '23 01:04 Rich-Harris

is there any news on this topic? thanks.


edit:

from I saw on twitter,
this functionality can be shown in the

SVELTE SUMMIT SPRING MAY 6 2023 THE 6TH VIRTUAL SVELTE CONFERENCE

image

by @geoffrich


so if I am right, after the next week we can hopefully use this functionality in our sveltekit projects? correct @Rich-Harris, thanks for the answer

Laaouatni avatar Apr 29 '23 14:04 Laaouatni

@KoRnFactory I used your post as inspiration. I think you can just use Svelte actions out-of-the-box once you have a way to call document.startViewTransition() globally for the app. I'm using the method @geoffrich showcased in his blog for now, but here's the Svelte action I used to replicate a fly transition, with in and out animations:

interface FlyTransition {
	duration?: number, // in ms
	direction?: string,
	animations?: string[], // animation names (i.e. your @keyframes declarations)
}
interface FlyParams extends FlyTransition {
	in_transition?: FlyTransition
	out_transition?: FlyTransition
}

function map_animations(duration: number, direction: string, animations: string[]){
	return animations.map(a => `${duration}ms ${direction} ${a}`).join(', ') 
}

function create_transition_id(node: HTMLElement){
	const unique_id = crypto?.randomUUID();
	node.style.setProperty("view-transition-name", unique_id)
	return unique_id
}

function initialize_view_transition(node: HTMLElement){
        const id = create_transition_id(node);
        const stylesheets = document?.styleSheets;
        const style_sheet = stylesheets[0];
        return {style_sheet, id}
}

function insert_rule_for_transition_element({style_sheet, id, animation, type = 'group'} : { style_sheet: CSSStyleSheet, id: string, animation: string, type?: 'new' | 'old' | 'group' }){
	style_sheet?.insertRule(`html::view-transition-${type}(${id}) {
		animation: ${animation};
	}`)
}

export function fly(node: HTMLElement, params: FlyParams = {}){
	// Use or override defaults
	const {
		duration = 400,
		direction = 'both',
		animations = ['svelte-fly-from-left', 'svelte-fade-in'],
		in_transition,
		out_transition
	} = params
	
	const {id, style_sheet} = initialize_view_transition(node)

	if(in_transition || out_transition){
		if(in_transition){
			const {duration = params.duration ?? 400, direction = 'both', animations = ['svelte-fly-from-left', 'svelte-fade-in'] } = in_transition
			const animation = map_animations(duration, direction, animations);
			insert_rule_for_transition_element({style_sheet, id, animation, type: 'new'});
		}
		if(out_transition){
			const {duration = params.duration ?? 400, direction = 'both', animations = ['svelte-fly-to-right', 'svelte-fade-out'] } = out_transition
			const animation = map_animations(duration, direction, animations);
			insert_rule_for_transition_element({style_sheet, id, animation, type: 'old'});
		}
	}else{
		const animation = map_animations(duration, direction, animations)
		insert_rule_for_transition_element({style_sheet, id, animation});
	}

}
@keyframes svelte-fade-in {
	from {
		opacity: 0;
	}
}
@keyframes svelte-fly-from-left {
	from {
		translate: -70%;
		pointer-events: none;
	}
}
@keyframes svelte-fade-out {
	to {
		opacity: 0;
	}
}
@keyframes svelte-fly-to-right {
	to {
		translate: 70%;
		pointer-events: none;
	}
}

One nice feature of this is that Svelte could just ship view transitions with its own extra stylesheet of @keyframes declarations, and users could easily override them or make their own. The downside to this method --at least, as I have it written above-- is that it requires users to author CSS animations rather than just tweak some params, which requires less CSS knowledge. I can't discount my bias for the more flexible authorship, but a huge appeal of Svelte transitions to most people, is their ease of use without having to know how to write animations. You could of course also write a different version of this transition that keeps the behavior closer to Svelte's current interface.

Anyways, the view transitions run really smoothly with very little overhead. The only jank right now is what Geoff and Rich described above. Ideally, we need to call the document.startViewTransition() from the onNavigate() lifecycle hook instead of beforeNavigate(). Otherwise, when clicking a link to another page, the DOM changes occur before the transition snapshot (try clicking the links in the demo below). You can still observe the correct transition by using back/forward navigation though, which is kind of interesting.

Here's my current working example: https://stackblitz.com/edit/sveltejs-kit-template-default-nrvhjq?file=src/lib/page-transitions.ts

jrmoynihan avatar May 13 '23 16:05 jrmoynihan

Thought a bit more about errors in lifecycle functions and decided that that's an issue we need to tackle separately, since it's not specific to onNavigate. So I think this is ready for merge

Rich-Harris avatar May 17 '23 18:05 Rich-Harris

Is there anything preventing merge ?

gterras avatar Jul 27 '23 22:07 gterras

I think the lint error is coming from dts-buddy. I filed an issue for it: https://github.com/Rich-Harris/dts-buddy/issues/34

benmccann avatar Aug 17 '23 20:08 benmccann

I feel like this could use some docs probably in the "Advanced routing" section. Right now beforeNavigate, onNavigate, and afterNavigate can only be discovered via the modules section api documentation, but there's nothing in the guide, the docs for onNavigate are quite far away from the other two, and there isn't anything that describes the navigation lifecycle.

benmccann avatar Aug 21 '23 04:08 benmccann

Because Github hides it, I'll add this comment again:

Feels a bit loose to just return strings in the error. Should we declare a set of consts that are reused? And should it just be "navigation was aborted" or a more techincal/stable-looking thing like NAV_ABORTED?

const NAV_ABORTED = 'NAV_ABORTED';
const NAV_CANCELLED = 'NAV_CANCELLED';

dummdidumm avatar Aug 29 '23 07:08 dummdidumm

What would the difference be in practical terms?

I think there is scope to make error messages more structured by using error codes that correspond to pages in the docs, but that's a larger issue — it applies to 'Redirect loop' and 'Can only disable scroll handling...' and 'Cannot use reserved query parameter...' and so on. It would feel weird to come up with something only for this feature. I think we should tackle that separately and not let it block this PR.

Rich-Harris avatar Aug 29 '23 13:08 Rich-Harris

Is there a way to catch this error (that was not present in <1.24.0)? As it may happen quite often in some use cases.

Smirow avatar Aug 31 '23 09:08 Smirow

Which error? Please provide more details and possibly open an issue with a reproduction.

dummdidumm avatar Aug 31 '23 11:08 dummdidumm

The errors discussed above when aborting/cancelling navigation: https://github.com/sveltejs/kit/pull/9605/files/eff8b4a84da082d579ce9c22867e8be3e33d6d43#r1297641206

They keep triggering when the user cancel the navigation, on multiple form submission (with method='GET') for instance.

Smirow avatar Aug 31 '23 11:08 Smirow

Could you open a new issue with a reproduction and explanation why this error is introducing unwanted behavior?

dummdidumm avatar Aug 31 '23 11:08 dummdidumm

It isn't much about unwanted behavior than error keep getting logged on the client side whenever the client willingly abort a navigation. I'll open an issue with a repro later.

Smirow avatar Aug 31 '23 12:08 Smirow

Can I ask if adding

onNavigate(() => {
  if (!document.startViewTransition) return;

  return new Promise((fulfil) => {
    document.startViewTransition(() => new Promise(fulfil));
  });
});

to the root layout is expected to lower global performance in any way ?

gterras avatar Sep 04 '23 13:09 gterras

@gterras The promise inside the startViewTransition in your example will never resolve, so the page will just freeze until the browser thinks it's getting too late and should end the transition

gtm-nayan avatar Sep 04 '23 16:09 gtm-nayan

@gterras The promise inside the startViewTransition in your example will never resolve, so the page will just freeze until the browser thinks it's getting too late and should end the transition

Isn't it the same code as in Rich's opening thread?

gterras avatar Sep 04 '23 16:09 gterras

Looks like it, although be aware that there was a change in the onNavigate API after the initial post. The original code could have been Promise.resolve() or maybe document.startViewTransition behaved differently at that point.

You can observe the freeze in this example:

<h1
	on:click={
		() => {
			new Promise(fulfil => document.startViewTransition(_ => new Promise(fulfil)))
		}
	}
>
	Hello {name}!
</h1>

gtm-nayan avatar Sep 04 '23 16:09 gtm-nayan

Looks like it, although be aware that there was a change in the onNavigate API after the initial post. The original code could have been Promise.resolve() or maybe document.startViewTransition behaved differently at that point.

Ok thanks that would explain why I got some slowdowns testing the API. I didn't check the FAQ updated code which is for reference

onNavigate((navigation) => {
    if (!document.startViewTransition) return;

    return new Promise((resolve) => {
        document.startViewTransition(async () => {
            resolve();
            await navigation.complete;
        });
    });
});

So back to my original question, this code isn't expected to slow down anything (provided you don't actually use the transition API in components) right?

gterras avatar Sep 04 '23 16:09 gterras

provided you don't actually use the transition API

wdym? Calling that code is using the transition API, so any navigation will have the transition (250ms by default IIRC) applied during which time your page will essentially be showing a non-interactive screenshot of itself.

gtm-nayan avatar Sep 05 '23 02:09 gtm-nayan

provided you don't actually use the transition API

wdym? Calling that code is using the transition API, so any navigation will have the transition (250ms by default IIRC) applied during which time your page will essentially be showing a non-interactive screenshot of itself.

No I mean while disabling any transition behavior (like the default fade) though CSS. Does the very presence of this code, provided you disable anything related to viewtransitions, is subject to even the slightest slowdown? Even a few ms?

gterras avatar Sep 05 '23 08:09 gterras