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

New events for Astro's view transition API #9090

Merged
merged 14 commits into from
Nov 22, 2023
Merged

New events for Astro's view transition API #9090

merged 14 commits into from
Nov 22, 2023

Conversation

martrapp
Copy link
Member

@martrapp martrapp commented Nov 13, 2023

Changes

Implementation for withastro/roadmap#740.
The earlier sections of the discussion document are out of date and merely describe the discussions we have had and the options we have considered. The content from 11/5 onwards is what will be implemented in this PR.

It might be beneficial to look at the larger diff of router.ts with the "hide whitespace options".

No changeset right now. Is this a minor?

Testing

The current tests still run without failures

Docs

How I would like to write again that these are just bug fixes, but I suspect that this time there is really something to do with the documentation. Sarah, do you have a week or two please?

Here are some paragraphs (from a technical point of view) describing the structure of the events.

Coming soon: I'm also planning some paragraphs explaining what cool things you can do with these events. (User perspective)

View Transition Events (Technical View)

View Transition Processing (bigger picture)

There are currently four types of actions that trigger Astro's View Transitions:

  1. cLicking on a link
  2. submitting a form
  3. triggering history navigation browser user interface buttons, keyboard shortcuts, or calls to history functions back(), forward(), or go()
  4. calling the function navigate(), which is provided by astro:transitions/client

Processing begins with a preparation phase that loads the DOM of the target page. Then the actual view transition begins, which takes a screenshot of the current view (green line), swaps the current DOM with the contents of the loaded DOM and then starts the visual transition from the old to the new view (red line).

graph
subgraph nav["Client-Side Navigation"]
  subgraph vtp["View Transition Promises"]
  direction LR
  g>updateCallbackDone]~~~h>ready]~~~i>finished]
  end
  subgraph p["Preparation Phase"]
    a{{astro:before-preparation}}-->
    b[["Loader()"<br>load DOM of target page]]-->
    c{{astro:after-preparation}}
  end
  subgraph vt["View Transition"]
    d([startViewTransition])
    subgraph s["Swap DOM Phase"]
      t{{astro:before-swap}}-->
      u[["Swap()"<br>update current DOM<br>with loaded DOM]]-->
      v([update history state<br>and scroll position])-->
      w{{astro:after-swap}}
    end
    subgraph f["Completion Phase"]
      x([Run scripts])-->
      y{{astro:page-load}}-->
      z([Announce Route])-->
      zz([await finished])-->
      zzz([Cleanup])
    end
  end
  p-->d==>s==>f
  d-.->vtp
end

linkStyle 13 stroke:#faa
linkStyle 12 stroke:#afa

style b stroke:#585,stroke-width:1px,color:#fff
style nav fill:#888,stroke:#555,stroke-width:4px,color:#fff
style p fill:#564,stroke:#555,stroke-width:4px,color:#fff
style vt fill:#456,stroke:#555,stroke-width:4px,color:#fff
style s fill:#457,stroke:#777,stroke-width:4px,color:#fff
style vtp fill:#467,stroke:#777,stroke-width:4px,color:#fff
style f fill:#654,stroke:#777,stroke-width:4px,color:#fff
style x stroke:#855,stroke-width:1px,color:#fff
style z stroke:#855,stroke-width:1px,color:#fff

style a fill:#fea,stroke:#ddd,stroke-width:2px,color:#000
style c fill:#fea,stroke:#ddd,stroke-width:2px,color:#000
style t fill:#fea,stroke:#ddd,stroke-width:2px,color:#000
style w fill:#fea,stroke:#ddd,stroke-width:2px,color:#000
style y fill:#fea,stroke:#ddd,stroke-width:2px,color:#000
Loading

In the completion phase, the newly added scripts are executed, the page title of the new route is announced for users of assistive technologies and some clean-up work is carried out at the end.

During the view transition, a view transition object exists with three promises that are resolved or rejected at different points during the visual transition. Users can use these to trigger custom code. For details see the View Transition documentation on MDN.

Events

The yellow blocks in the diagram above mark the positions in Astro's navigation processing at which events are triggered. There are five events.

  • astro:before-preparation and astro:after-preparation at the beginning and end of the preparation phase.
  • astro:before-swap and astro:after-swap at the beginning and end of the swap phase.
  • astro:page-load during the closing phase.

The astro:after- events and the astro:page-load event are standard Event objects. Their main purpose is to allow the user code to react to navigation behavior at different points of processing.

The astro:before events provide navigation-specific properties
that show details of the processing. Some of these properties are even writable to give users control over the behavior of navigation processing.

All five events fire on window.document.

The astro:before-preparation Event

readonly from: URL // The page where the navigation started 
to: URL // The destination of the navigation. 
direction: Direction | string // The values directly supported by Astro are 'forward' and 'backward', but this can be extended to other values. This property is writable.
readonly navigationType: NavigationTypeString // 'traverse' | 'push' | 'replace' 
readonly sourceElement: Element // If triggered by a link navigation, the anchor element. If triggered by form submission, the submitter (and if submitter is null, the form element). Can also be set via the sourceElement property of the options parameter on a call to navigate()
readonly info: any // If the transition was initiated by a call to navigate(), the value of options.info. Set to an empty object if undefined.
newDocument: Document // The DOM to be transitioned to. This can be an empty DOM if swap() manipulates the current DOM in place.
readonly formData: FormData // Automatically filled in if the navigation was triggered by a form. If the navigation was triggered by a call to navigate(), the value of options.formData.
loader: () => Promise<void>  // A function that sets event.newDocument to the contents of event.to.

While the event listener code must run synchronously, the loader function will perform asynchronous actions.

The loader property initially holds Astro's built-in implementation for loading the contents of event.to into event.newDocument. An event listener might override the value of the loader property to define a completely independent implementation. But more often than not, a listener will keep the original value of loader and use it to define its own version.

const originalLoader = event.loader;
event.loader = async () => {
  doSomeThingBefore(event);
  await originalLoader();
  doSomeThingAfter(event)
}

Several listener might cooperate and build a Chain of Responsibility by wrapping the loader in several layers like an onion.

graph TD
subgraph a["render loading indicator"]
subgraph b["prefetch target images"]
subgraph c["optimize off-viewport transitions"]
subgraph d["original loader"]
end end end end

style a fill:#9bf,stroke:#555,stroke-width:2px,color:#000
style b fill:#8ae,stroke:#555,stroke-width:2px,color:#000
style c fill:#79d,stroke:#555,stroke-width:2px,color:#000
style d fill:#68c,stroke:#555,stroke-width:2px,color:#000
Loading
Type astro:before-preparation
TypeScript Type TransitionBeforePreparationEvent
Cancelable yes

The astro:before-swap Event

Most of the properties of the astro:before-swap event are identical to those of the astro:before-preparation event. The differences are:

  • direction is readonly in astro:before-swap
  • loader is not defined in astro:before-swap
    The astro:before-swapevent offeres two additional properties:
swap: () => void // Initially, Astro's built-in implementation of the swap() operation. The task of the swap operation is to update the current DOM, typically to reflect the contents of event.newDocument.  
readonly viewTransition: ViewTransition // The object returned by startViewTransition().  

The swap() method of the astro:before-swap event is used in a very similar way as loader() of astro:before-preparation event. It is possible to override the built-in implementation by assigning to the swap property and it is possible to build chains with several wrappers. Other tan loader swap does not support asynchronous actions. Browser implementations of view transition put a rather rigid timout on the code that can run during swap. Long running code should be moved to the loader callback.

Type astro:before-swap
TypeScript Type TransitionBeforeSwapEvent
Cancelable no

Data flow between astro:before-* events:

The initial state of the astro:before-swap event is derived from the final state of the astro:before-preparation event of the same transition. Data set by a listener can be seen by subsequent listeners. Data entered during the astro:before-preparation dispatch can be seen during the astro:before-swap dispatch. This also applies in particular to the (user) info object that exists in every event.

Copy link

changeset-bot bot commented Nov 13, 2023

🦋 Changeset detected

Latest commit: 10234e2

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

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

@martrapp martrapp marked this pull request as draft November 13, 2023 19:41
@github-actions github-actions bot added pkg: astro Related to the core `astro` package (scope) pr: docs A PR that includes documentation for review labels Nov 13, 2023
@martrapp martrapp marked this pull request as ready for review November 13, 2023 22:22
@matthewp
Copy link
Contributor

I can work on the docs for this one.

@martrapp
Copy link
Member Author

I can work on the docs for this one.

I would be very happy if you would do that! It's always so grueling to write good documentation in a foreign language!

@sarah11918
Copy link
Member

Hey @martrapp and @matthewp ! I'm starting to dig into this to be ready for docs, and just a heads up that in addition to this now-complete picture of things as a whole, some things that will help docs fall into place:

  • calling attention to which of these events that users can tap into are new (because I believe they are not all new). Highlighting what's new, and relating that to what existed before, is important to convey. (astro:before-swap, astro:after-preparation, astro:before-preparation are the only new events?) And, whether these new events have caused any changes to existing events.

  • a way to frame what this (now) gives you:

    • finer control over the whole process with more events during the whole navigation process to tap into (so, more choice of doing things at a more precise time)?
    • More strategically-placed events (so, now the actions you want triggered are at better parts in the cycle)?
    • Events specifically to correspond to exact things you want to do? (e.g. not just MORE events to hit, but some CUSTOM events that are intended for very specific use cases/actions?)

    Understanding not just the cycle as one big picture, but what is different/improved is always helpful for docs!

  • And then as you already meantioned, Martin, some user examples showing how these new events can be used, which should include:

    • Things you can do now that you couldn't before
    • Things that you would do differently now because of the new events

The docs site itself will really just document how things work as we don't describe changes, or past version there. But for updating existing docs (esp. if recommendations are different now), the changeset, blog post, and for helping our support squad understand what's different so they can answer people's questions, paying some attention now to the differences is helpful.

@matthewp
Copy link
Contributor

@sarah11918 Yeah there's one case where you can do darkmode with one of the new events that might be cleaner than the existing after-swap suggest. I'll take that into account and update the docs accordingly.

@martrapp
Copy link
Member Author

Hi @sarah11918 , nice of you to come on board! Even if it is partially outdated, the information in withastro/roadmap#740 can help to understand how things came to be.

I've changed the color coding in the diagram at the top of the page to better represent what's old and what's new. This is exactly as you have already guessed.

As announced above, a few paragraphs from the user perspective that might also help define good documents:

There are now 5 events? How do I choose the right one?

The events are assigned to five positions in the transition process: The beginning and the end of the preparation phase, the beginning and the end of the swap phase and during the closing phase. Three of them (astro:after-preparation, astro:after-swap, astro:page-load) are used to inform your code that a certain point in the processing has been reached. The other two events (astro:before-preparation and astro:before-swap) have additional properties and functions to control the transition process.

While some things can be done in any event, some tasks can only be performed in a specific event, see details below.

I want to have different behaviors for different links on the page.

You can use the sourceElement property of the astro:before-* events to access the anchor element, button or form that triggered the navigation. There you can test for additional information such as the values of data attributes or style classes.

I would like to display some "loading..." indicator

Use the event listener of the astro:before-preparation event to activate the indicator. Hide the indicator in the event listener for the astro:after-preparation event. This ensures that the indicator is removed before the view transition and is not part of the initial screenshot. It is not necessary to set the loader callback or change the event properties.

I want to add my own custom animation

Wait for the ready promise of the viewTransition object. Or have your animation triggered by the insertion of the pseudo elements of the View Transition API, see details here

I want to prefetch images on the target page

View transitions from thumbnails to large images in target files look very ugly if the target image is not available when the transition starts. For better results, preload the images before the view transition begins: Define a new function loader for the astro:before-preparation event. Call the original loader function to do the heavy lifting and get the content of the next page in the newDocument property. Find the images you want to preload and load them into the cache. Use Promise.all() to wait for all images to be ready.

I want to optimize the number of objects participating in a view transition

In the listener for the astro:before-swap event, you have access to the current and future page content. At this point, you could remove animation pairs that would otherwise lead to ugly fly-throughs if some of them are outside the current viewport and others are not. You could also introduce new pairs right before the view transition to optimize the effect of the ::view-transition-group at the last minute.

I want to use a different loader that can output percentage events during loading.

Yes, you can do that by overriding the loader property of the astro:before-preparation event. You should take a look at the current source code and copy most of it, as it has already undergone some bug fixes to handle corner cases.

The default swapping of View Transitions resets iframes and animations.

Define your own swap algorithm: Override the swap property in the astro:before-swap event. Instead of casting the newDocument to the old one, make a diff of the two structures and change only the absolute minimum of the existing DOM to finally reflect the desired result.

Do all event listeners and callbacks have to be synchronous?

All event listeners should only execute synchronous code. The reason for this is that EventTarget.dispatchEvent() cannot be awaited for. A further restriction arises from the way view transitions work. While the browser executes the code between astro:before-swap and astro:after-swap, the user interface is frozen. There is also a strict timeout to ensure that this freeze does not last too long. For this reason, the swap callback of the astro:before-swap event is not awaited for. The loader callback of the astro:before-preparation event can run asynchronous code, see below.

You can use asynchronous code in the event listeners and callbacks, but the processing would not wait for this code to complete and it would actually be executed in parallel with the view transition. So this is clearly not recommended.

I need to run asynchronous code during the transition and wait

The only way to execute asynchronous code during the transition and await its completion is to execute it via the loader hook of the astro:before-preparation event. You can use this hook to load files from the net, compile Ada into WASM, ask ChatGPT for help, or perform any other time-consuming preparation your transition requires.

@matthewp
Copy link
Contributor

I just added a draft PR of the updated docs and @martrapp's explanations above blows away mine :D withastro/docs#5425

@github-actions github-actions bot removed the pr: docs A PR that includes documentation for review label Nov 15, 2023
@martrapp
Copy link
Member Author

Two new tech demos on https://events-3bg.pages.dev/:
a) Loading indicator and
b) swap() replacement that persists animation and iframe without resetting them.

@github-actions github-actions bot added pr: docs A PR that includes documentation for review semver: minor Change triggers a `minor` release labels Nov 18, 2023
Copy link
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is blocked because it contains a minor changeset. A reviewer will merge this at the next release if approved.

@martrapp
Copy link
Member Author

@matthewp we haven't talked explicitly on the current state of the options parameter of navigate(), see packages/astro/src/transitions/types.ts:
state and info are perhaps worth mentioning: together with history we now support all properties of the options parameter of navigation.navigate(). Should we put a _ in front of formData and sourceElement to mark them for internal use?

@matthewp
Copy link
Contributor

Do you mean like this?

navigate("/url", { _formData: formData })

If so, no I don't think we should do that. The alignment with navigator.navigate() is nice for people who know about it, but the majority will not and adding underscores just makes the API confusing imo.

@martrapp
Copy link
Member Author

If so, no I don't think we should do that. The alignment with navigator.navigate() is nice for people who know about it, but the majority will not and adding underscores just makes the API confusing imo.

OK, fine with me!

@bluwy
Copy link
Member

bluwy commented Nov 21, 2023

I only skimmed through the code, and I think Matthew has the best knowledge to review this, but I'm slightly concerned of the bundle size increase this feature brings. The code to create events are taking a lot for something that might not be used.

Testing locally with examples/view-transitions, the client build JS reports:

Before:  9.31 kB │ gzip: 3.42 kB
After : 11.72 kB │ gzip: 4.13 kB

It's around 20% size increase. I don't think this is a blocker for the PR, but something I thought to bring up.

Copy link
Member

@sarah11918 sarah11918 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing work @martrapp ! The care and detail into this feature is truly outstanding!

Just a question to verify re: connecting this to what's in Docs proper, and suggested updating to a more direct link for the lifecycle events!

.changeset/few-keys-heal.md Outdated Show resolved Hide resolved
```

The `astro:before-*` events allow you to change properties and strategies of the view transition implementation.
The `astro:after-*` events allow you to make changes to the current DOM before and after the transition.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll note that saying it's for "before and after" is maybe confusing here, since we're trying to distinguish before- events from after- events?

The explanation on the docs PR refers to after events firing "when a particular phase has finished".

The distinction between these two here makes it sound like "before" is used for changing how a process occurs, but "after" sounds like it only changes WHAT gets written to the DOM. Is that correct? Are both of these correct? If so, maybe check that "tip" in the docs section for updating/nuance!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Argh, 😄 that was really confusing, thanks for pointing it out! I like the wording in the "tip" much better. The after event is not about what is written to the DOM. Making changes to the DOM was just one example of what you can do when you know a phase is over.

@matthewp
Copy link
Contributor

@bluwy Yeah I have similar concerns but I don't think that means we shouldn't release this. This is probably the biggest increase we've had to the router, but it's probably the last big one we'll ever do.

The initial version of the router was not configurable, but now with the addition of the events and navigate(), we should be able to prevent any future feature creep as the router now allows you to hook into each phase.

There might be some opportunities to trim down the size in the future as well.

Copy link
Member

@sarah11918 sarah11918 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a formality, but Docs is happy! 🥳

@matthewp matthewp merged commit c87223c into main Nov 22, 2023
13 checks passed
@matthewp matthewp deleted the mt/events branch November 22, 2023 12:54
@astrobot-houston astrobot-houston mentioned this pull request Nov 22, 2023
natemoo-re pushed a commit that referenced this pull request Nov 22, 2023
* draft new view transition events

* initial state for PR

* remove intraPageTransitions flag based on review comments

* add createAnimationScope after review comments

* remove style elements from styles after review comments

* remove quotes from animation css to enable set:text

* added changeset

* move scrollRestoration call from popstate handler to scroll update

* Update .changeset/few-keys-heal.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Less confusing after following review comments

* Less confusing after following review comments

---------

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
pkg: astro Related to the core `astro` package (scope) pr: docs A PR that includes documentation for review semver: minor Change triggers a `minor` release
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants