Navigation
Many modern web applications are built as "Single Page Applications" (SPAs). In these applications, each page rendered by the application no longer requires a full browser page reload, avoiding the overhead of re-downloading JavaScript and CSS assets on every request.
The alternative to a single page application is a multi-page application. In these applications, every time a user clicks a link, an entirely new HTML page is requested and rendered in the browser.
While most Python web applications have traditionally been multi-page applications, PyBlade Live offers a single page application experience via a simple attribute you can add to links in your application: pb:navigate.
Basic usage
Let's explore an example of using pb:navigate. Below is a typical Django urls file (my_project/my_project/urls.py) with three PyBlade Live components defined as views:
from django.urls import path
from live import Dashboard, PostList, UserList
urlpatterns = [
path("/", Dashboard.as_view(), name="dashboard"),
path("posts/", PostList.as_view(), name="posts"),
path("/users/", UserList.as_view(), name="users")
]By adding pb:navigate to each link in a navigation menu on each page, PyBlade Live will prevent the standard handling of the link click and replace it with its own, faster version:
<nav>
<a href="/" pb:navigate>Dashboard</a>
<a href="/posts" pb:navigate>Posts</a>
<a href="/users" pb:navigate>Users</a>
</nav>Below is a breakdown of what happens when a pb:navigate link is clicked:
- User clicks a link
- PyBlade Live prevents the browser from visiting the new page
- Instead, PyBlade Live requests the page in the background and shows a loading bar at the top of the page
- When the HTML for the new page has been received, PyBlade Live replaces the current page's URL, its
<title>and the part of the page that changes from one page to the next with the ones of the new page
The part that changes is the element of your layout marked pb:root. Everything outside it — a sidebar, a header, a player — stays on the page untouched, with whatever state it holds:
<body>
<nav>...</nav>
<main pb:root>
{{ slot }}
</main>
@pbscripts
</body>A layout that marks no element has its whole <body> replaced.
This technique results in much faster page load times — often twice as fast — and makes the application "feel" like a JavaScript powered single page application.
Pro tip !
Instead of using a URL pattern, you may use the URL name with the @url PyBlade directive.
<a href="@url('dashboard')" pb:navigate>Dashboard</a>Redirects
When one of your PyBlade Live components redirects users to another URL within your application, you can also instruct PyBlade Live to use its pb:navigate functionality to load the new page. To accomplish this, provide the navigate argument to the redirect() method:
return self.redirect('posts/', navigate=True)Now, instead of a full page request being used to redirect the user to the new URL, PyBlade Live will replace the contents and URL of the current page with the new one.
Prefetching links
By default, PyBlade Live includes a gentle strategy to prefetch pages before a user clicks on a link:
- A user presses down on their mouse button
- PyBlade Live starts requesting the page
- They lift up on the mouse button to complete the click
- PyBlade Live finishes the request and navigates to the new page
Surprisingly, the time between a user pressing down and lifting up on the mouse button is often enough time to load half or even an entire page from the server.
If you want an even more aggressive approach to prefetching, you may use the .hover modifier on a link:
<a href="posts/" pb:navigate.hover>Posts</a>The .hover modifier will instruct PyBlade Live to prefetch the page after a user has hovered over the link for 60 milliseconds.
Prefetching on hover increases server usage
Because not all users will click a link they hover over, adding .hover will request pages that may not be needed, though PyBlade Live attempts to mitigate some of this overhead by waiting 60 milliseconds before prefetching the page.
Persisting elements across page visits
Sometimes, there are parts of a user interface that you need to keep between page visits, such as audio or video players. For example, in a podcasting application, a user may want to keep listening to an episode as they browse other pages.
You can achieve this in PyBlade Live with the @persist directive.
Wrap an element with @persist and give it a name. When a new page is visited with pb:navigate, PyBlade Live looks for a @persist of the same name on the new page. Instead of drawing the element again, it moves the element from the previous page into the new one, preserving everything about it: an audio player keeps playing, a video keeps its position, a live component inside keeps its state.
@persist('player')
<audio src="{{ episode.file }}" controls></audio>
@endpersistIf the above appears on both pages — the current page and the next one — the audio playback won't be interrupted when navigating from one to the other. The element doesn't have to be in the same place on both pages: it goes wherever the new page's @persist is. If the new page has no @persist of that name, the element leaves with the old page, as anything else on it would.
@persist is for elements inside the part of the page that navigation replaces, the element marked pb:root. Here, the episode page shows a large player, and the episode list shows the same player in a corner:
<div>
<h1>{{ episode.title }}</h1>
@persist('player')
<audio src="{{ episode.file }}" controls></audio>
@endpersist
<a href="/episodes/" pb:navigate>All episodes</a>
</div><div>
@for(episode in episodes)
<a href="/episodes/{{ episode.id }}/" pb:navigate>{{ episode.title }}</a>
@endfor
<aside class="mini-player">
@persist('player')
<audio src="{{ now_playing.file }}" controls></audio>
@endpersist
</aside>
</div>Outside pb:root, nothing needs persisting
Anything in your layout outside the element marked pb:root is never replaced when navigating, so it stays on the page without @persist.
Persisting through updates
A @persist block inside a live component is also kept as it is when that component updates. A video keeps playing while the comments under it change:
<div>
@persist('stream')
<video src="/streams/keynote.m3u8" autoplay controls></video>
@endpersist
<form pb:submit="post_comment">
<input type="text" pb:model="body">
<button type="submit">Send</button>
</form>
@for(comment in comments)
<p>{{ comment }}</p>
@endfor
</div>How it is rendered
@persist wraps its content in a <div> carrying its name:
<div data-pb-persist="player">
<audio src="/episodes/42.mp3" controls></audio>
</div>That attribute is how PyBlade Live finds the element in the browser. It isn't a directive to write by hand: @persist is the only way to persist an element.
Highlighting active links
You might be used to highlighting the currently active page link in a navbar using server-side PyBlade like so:
<nav>
<a href="/" class="@active('dashboard')font-bold text-zinc-800@endactive">Dashboard</a>
<a href="posts/" class="@active('posts')font-bold text-zinc-800@endactive">Posts</a>
<a href="users/" class="@active('users')font-bold text-zinc-800@endactive">Users</a>
</nav>However, this will not work inside persisted elements as they are re-used between page loads. Instead, you should use PyBlade Live's pb:current directive to highlight the currently active link.
Simply pass any CSS classes you want to apply to the currently active link to pb:current:
<nav>
<a href="/" ... pb:current="font-bold text-zinc-800">Dashboard</a>
<a href="posts/" ... pb:current="font-bold text-zinc-800">Posts</a>
<a href="users/" ... pb:current="font-bold text-zinc-800">Users</a>
</nav>Now, when the posts/ page is visited, the "Posts" link will have a stronger font treatment than the other links.
Read more in the pb:current documentation.
Preserving scroll position
By default, PyBlade Live will preserve the scroll position of a page when navigating back and forth between pages. However, sometimes you may want to preserve the scroll position of an individual element you are persisting between page loads.
To do this, you must add pb:scroll to the element containing a scrollbar like so:
@persist('scrollbar')
<div class="overflow-y-scroll" pb:scroll>
<!-- ... -->
</div>
@endpersistJavaScript hooks
Each page navigation dispatches two events on window:
pb:navigating, when a navigation starts, before the new page is requestedpb:navigated, once the new page is in place
Both carry the address being visited in event.detail.href. They are dispatched for every navigation made by PyBlade Live: a pb:navigate link, PyBlade.navigate(), a redirect with navigate=True, and the browser's back and forward buttons.
window.addEventListener('pb:navigating', (event) => {
// A navigation to event.detail.href is starting...
});
window.addEventListener('pb:navigated', (event) => {
// The page at event.detail.href is now in place...
});pb:navigated is not dispatched when the first page loads: that is the browser's own DOMContentLoaded.
Event listeners will persist across pages
When you attach an event listener to window or document, it is not removed when you navigate to a different page. This can lead to unexpected behaviour if you need code to run only after navigating to a specific page, or if you add the same event listener on every page. If you do not remove your event listener it may cause exceptions on other pages when it's looking for elements that do not exist, or you may end up with the event listener executing multiple times per navigation.
An easy method to remove an event listener after it runs is to pass the option { once: true } as a third parameter to the addEventListener function.
window.addEventListener('pb:navigated', () => {
// ...
}, { once: true })Manually visiting a new page
In addition to pb:navigate, you can call PyBlade.navigate() to visit a new page from JavaScript:
<script>
// ...
PyBlade.navigate('/new/url/')
</script>Using with analytics software
When navigating pages using pb:navigate in your app, any <script> tags in the <head> only evaluate when the page is initially loaded.
This creates a problem for analytics software such as Fathom Analytics. These tools rely on a <script> snippet being evaluated on every single page change, not just the first.
Tools like Google Analytics are smart enough to handle this automatically, however, when using Fathom Analytics, you must add data-spa="auto" to your script tag to ensure each page visit is tracked properly:
<head>
<!-- ... -->
<!-- Fathom Analytics -->
<script src="https://cdn.usefathom.com/script.js" data-site="ABCDEFG" data-spa="auto" defer></script>
</head>Script evaluation
When navigating to a new page using pb:navigate, it feels like the browser has changed pages; however, from the browser's perspective, you are technically still on the original page.
Because of this, styles and scripts are executed normally on the first page, but on subsequent pages, you may have to tweak the way you normally write JavaScript.
Here are a few caveats and scenarios you should be aware of when using pb:navigate.
Don't rely on DOMContentLoaded
It's common practice to place JavaScript inside a DOMContentLoaded event listener so that the code you want to run only executes after the page has fully loaded.
When using pb:navigate, DOMContentLoaded is only fired on the first page visit, not subsequent visits. To run code on every page:
- write it in a
<script>in the page itself, which is run on every visit, - or, for code that belongs to a live component, write it in the component's
@script, - or listen for both events:
const init = () => {
// ...
};
document.addEventListener('DOMContentLoaded', init);
window.addEventListener('pb:navigated', init);Scripts in <head> are loaded once
If two pages include the same <script> tag in the <head>, that script will only be run on the initial page visit and not on subsequent page visits.
<!-- Page one -->
<head>
<script src="/app.js"></script>
</head>
<!-- Page two -->
<head>
<script src="/app.js"></script>
</head>A script loading a file is recognized by its src, and one written inline by its whole content. Stylesheets and <style> elements follow the same rule.
New <head> scripts are evaluated
If a subsequent page includes a new <script> tag in the <head> that was not present in the <head> of the initial page visit, PyBlade Live will add it and run it. New stylesheets and <style> elements are added the same way.
In the below example, page two includes a new JavaScript library for a third-party tool. When the user navigates to page two, that library will be evaluated.
<!-- Page one -->
<head>
<script src="/app.js"></script>
</head>
<!-- Page two -->
<head>
<script src="/app.js"></script>
<script src="/third-party.js"></script>
</head>New scripts run before the page's own
When the new page is in place, new <head> scripts are run before the scripts of the page itself, and a script loading a file is waited for before the next one runs. A script in the page that uses a library loaded in the new <head> can therefore count on it being there.
Reloading when assets change
It's common practice to include a version hash in an application's main JavaScript file name. This ensures that after deploying a new version of your application, users will receive the fresh JavaScript asset, and not an old version served from the browser's cache.
But, now that you are using pb:navigate and each page visit is no longer a fresh browser page load, your users may still be receiving stale JavaScript after deployments.
To prevent this, you may add data-navigate-track to a <script> or <link> tag in <head>:
<!-- Page one -->
<head>
<script src="/app.js?id=123" data-navigate-track></script>
</head>
<!-- Page two -->
<head>
<script src="/app.js?id=456" data-navigate-track></script>
</head>When a user visits page two, PyBlade Live will detect a fresh JavaScript asset and load the page from scratch, with a full browser page load.
Only query string changes are tracked
PyBlade Live will only reload a page if a [data-navigate-track] element's query string (?id=456) changes, not the URI itself (/app.js).
Scripts in the page are re-evaluated
Because PyBlade Live replaces the part of the page marked pb:root on every new page (the whole <body> when nothing is marked), all <script> tags in it are run on each new page, as they would be on a page loaded from scratch:
<!-- Page one -->
<main pb:root>
<script>
console.log('Runs on page one')
</script>
</main>
<!-- Page two -->
<main pb:root>
<script>
console.log('Runs on page two')
</script>
</main>If you have a <script> tag that you only want to be run once, you can add the data-navigate-once attribute to it and PyBlade Live will only run it the first time:
<script data-navigate-once>
console.log('Runs only on page one')
</script>@pbscripts is marked this way already: PyBlade Live is never started twice.
Scripts outside pb:root, in your layout, are not replaced and are not run again.
Pushed content
The content your templates push with @push is kept once per page, and that holds across navigation too. The new page's pushes are added to the stacks of the current page, wherever those stacks are in the layout, and what the current page already holds is left as it is and not run again. A library loaded on one page is not loaded a second time on the next, and a library first needed on the next page is loaded and run there.
Customizing the progress bar
When a page takes longer than 150ms to load, PyBlade Live will show a progress bar at the top of the page.
You can customize the color of this bar or disable it all together inside PyBlade's config file (pyblade.toml):
[live_components.navigate]
show_progress_bar = true
progress_bar_color = "#2299dd"