Repository navigation
feat(html): client-side navigation with the Navigation API #1117
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
Merged
+1,132
−134
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
7ad849f
feat(html): client-side navigation with the Navigation API
ovflowd 1156dec
refactor(html): use hydrated set for island scroll saves; drop announ…
ovflowd 53a3b20
refactor(html): embed router data in HTML; extract shouldIntercept; d…
ovflowd 3277d1d
refactor(html): address review — move router constants, simplify remo…
ovflowd 7ba77eb
refactor(html): split page DOM utilities into page.mjs; extract shoul…
ovflowd 0a59f6e
fix(html): move router constants to ui/constants.mjs; keep node: impo…
ovflowd a4283d0
Merge branch 'main' into feat/client-side-navigation
ovflowd 6433551
Apply suggestion from @ovflowd
ovflowd ebdd8e7
fix(html): follow the host's redirect to clean URLs
ovflowd ddfd9a6
refactor(html): move PAGE_HEAD to the constants, destructure URL hrefs
ovflowd 3f9fe97
fix(html): keep the URL until the page arrives, then move it to where…
ovflowd e275afa
refactor(html): share attribute names and the page pattern as constan…
ovflowd 85949b0
docs: restore the vercel.json code block label
ovflowd 9418300
refactor(html): await an island's component on its own line
ovflowd 2da3404
fix(html): free fetched pages when they expire, and count the cache l…
ovflowd File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@doc-kit/generator-react': minor | ||
| --- | ||
|
|
||
| feat(html): navigate between pages client-side with the Navigation API, prefetching them on hover, keep the search index and remote config in memory across pages, scope speculation rules to links that leave the site, and hash fonts (reported through the bundler's new `fonts`) so every asset can be cached immutably |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
|
|
||
| const REMOTE_CONFIG_URL = 'https://nodejs.org/site.json'; | ||
|
|
||
| /** | ||
| * Navigates the way following a link does, and waits for the navigation to | ||
| * finish. | ||
| */ | ||
| const navigate = (page, url) => | ||
| page.evaluate(url => navigation.navigate(url).finished.then(() => {}), url); | ||
|
|
||
| test.describe('Client-side navigation', () => { | ||
| test.beforeEach(async ({ page }) => { | ||
| await page.route(REMOTE_CONFIG_URL, route => | ||
| route.fulfill({ | ||
| contentType: 'application/json', | ||
| body: JSON.stringify({ | ||
| websiteBanners: { index: { text: 'Important announcement' } }, | ||
| }), | ||
| }) | ||
| ); | ||
|
|
||
| await page.goto('/assert.html'); | ||
|
|
||
| // A full load would start a new document, and lose this | ||
| await page.evaluate(() => (window.__document = 'first')); | ||
| }); | ||
|
|
||
| test('swaps the next page in without loading assets again', async ({ | ||
| page, | ||
| }) => { | ||
| const loaded = await page.evaluate(() => | ||
| [ | ||
| ...document.querySelectorAll( | ||
| 'script[src], link[rel="stylesheet"], link[as="font"]' | ||
| ), | ||
| ].map(element => element.src || element.href) | ||
| ); | ||
|
|
||
| const requests = []; | ||
| page.on('request', request => requests.push(request.url())); | ||
|
|
||
| await navigate(page, 'all.html'); | ||
|
|
||
| // `serve` redirects `all.html` to `all`, as hosts with clean URLs do | ||
| await expect(page).toHaveURL(/\/all$/); | ||
| await expect(page).toHaveTitle(/^All \|/); | ||
| await expect(page.locator('meta[property="og:title"]')).toHaveAttribute( | ||
| 'content', | ||
| /^All \|/ | ||
| ); | ||
| expect(await page.evaluate(() => window.__document)).toBe('first'); | ||
|
|
||
| // The scripts, stylesheets and fonts are still loaded | ||
| expect(requests.filter(url => loaded.includes(url))).toEqual([]); | ||
| }); | ||
|
|
||
| test('moves the URL straight to the one the host redirects to', async ({ | ||
| page, | ||
| }) => { | ||
| // `serve` redirects `all.html` to `all`, as hosts with clean URLs do | ||
| const committed = await page.evaluate(() => | ||
| navigation.navigate('all.html').committed.then(({ url }) => url) | ||
| ); | ||
|
|
||
| expect(committed).toMatch(/\/all$/); | ||
| }); | ||
|
|
||
| test('goes back to the previous page, where it was scrolled to', async ({ | ||
| page, | ||
| }) => { | ||
| await page.evaluate(() => scrollTo(0, 2000)); | ||
| await navigate(page, 'all.html'); | ||
| await page.evaluate(() => navigation.back().finished.then(() => {})); | ||
|
|
||
| // Hosts with clean URLs redirect the first page to one without `.html` | ||
| await expect(page).toHaveURL(/\/assert(\.html)?$/); | ||
| await expect(page).toHaveTitle(/^Assert \|/); | ||
| expect(await page.evaluate(() => scrollY)).toBe(2000); | ||
| expect(await page.evaluate(() => window.__document)).toBe('first'); | ||
| }); | ||
|
|
||
| test('prefetches a page as its link is hovered', async ({ page }) => { | ||
| await page.evaluate(() => | ||
| document | ||
| .querySelector('main') | ||
| .insertAdjacentHTML('beforeend', '<a id="all" href="all.html">All</a>') | ||
| ); | ||
|
|
||
| // Hosts with clean URLs answer the prefetch with a redirect first | ||
| const prefetched = page.waitForResponse( | ||
| response => /\/all(\.html)?$/.test(response.url()) && response.ok() | ||
| ); | ||
|
|
||
| await page.hover('#all'); | ||
| await prefetched; | ||
|
|
||
| const requests = []; | ||
| page.on('request', request => requests.push(request.url())); | ||
|
|
||
| await page.click('#all'); | ||
|
|
||
| await expect(page).toHaveTitle(/^All \|/); | ||
| expect(requests).toEqual([]); | ||
| }); | ||
|
|
||
| test('keeps the remote config, and shows its banner at once', async ({ | ||
| page, | ||
| }) => { | ||
| const banner = page.getByRole('region', { name: 'Announcement' }); | ||
| await expect(banner).toBeVisible(); | ||
|
|
||
| let fetched = 0; | ||
|
|
||
| await page.route(REMOTE_CONFIG_URL, route => { | ||
| fetched++; | ||
| return route.fallback(); | ||
| }); | ||
|
|
||
| await navigate(page, 'all.html'); | ||
|
|
||
| await expect(banner).toBeVisible(); | ||
| // It animates in on the first page only | ||
| await expect(banner).toHaveCSS('animation-name', 'none'); | ||
| expect(fetched).toBe(0); | ||
| }); | ||
|
|
||
| test('leaves links to files that are not pages to the browser', async ({ | ||
| page, | ||
| }) => { | ||
| await page.getByRole('link', { name: 'JSON' }).click(); | ||
|
|
||
| await expect(page).toHaveURL(/\/assert\.json$/); | ||
| expect(await page.evaluate(() => window.__document)).toBeUndefined(); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.