Add handling for prefetching onTouchStart and initial mobile testing (#38805)
This adds handling for prefetching `onTouchStart` as this gives a little more time to start parsing required scripts for a page transition if not already done that can help make the transition faster. This is based on research showing the touch start event firing on average `90ms` before click (x-ref: [source](https://instant.page/#:~:text=in%20the%20world.-,On%20mobile,-A%20user%20starts)) This also adds testing safari with playwright so we can run these in PRs instead of only after merge and adds initial mobile testing as well. x-ref: [slack thread](https://vercel.slack.com/archives/C7PDM7X2M/p1658250170774989?thread_ts=1658249275.178349&cid=C7PDM7X2M) ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm lint` - [ ] The examples guidelines are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing.md#adding-examples)
This commit is contained in:
parent
05ba790cdb
commit
135a4cfc66
6 changed files with 131 additions and 44 deletions
15
.github/workflows/build_test_deploy.yml
vendored
15
.github/workflows/build_test_deploy.yml
vendored
|
@ -816,13 +816,9 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
needs: [build, build-native-test]
|
||||
env:
|
||||
BROWSERSTACK: true
|
||||
BROWSER_NAME: 'safari'
|
||||
NEXT_TELEMETRY_DISABLED: 1
|
||||
NEXT_TEST_MODE: 'start'
|
||||
SKIP_LOCAL_SELENIUM_SERVER: true
|
||||
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
|
||||
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
|
||||
NEXT_TELEMETRY_DISABLED: 1
|
||||
steps:
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
|
@ -851,12 +847,13 @@ jobs:
|
|||
- run: npm i -g pnpm@${PNPM_VERSION}
|
||||
if: ${{needs.build.outputs.docsChange == 'nope'}}
|
||||
|
||||
# TODO: use macos runner so that we can use playwright to test against
|
||||
# PRs instead of only running on canary?
|
||||
- run: '[[ -z "$BROWSERSTACK_ACCESS_KEY" ]] && echo "Skipping for PR" || npm i -g browserstack-local@1.4.0'
|
||||
- run: npx playwright install-deps && npx playwright install webkit
|
||||
if: ${{needs.build.outputs.docsChange == 'nope'}}
|
||||
|
||||
- run: '[[ -z "$BROWSERSTACK_ACCESS_KEY" ]] && echo "Skipping for PR" || node run-tests.js -c 1 test/integration/production/test/index.test.js test/e2e/basepath.test.ts'
|
||||
- run: node run-tests.js -c 1 test/integration/production/test/index.test.js test/e2e/basepath.test.ts
|
||||
if: ${{needs.build.outputs.docsChange == 'nope'}}
|
||||
|
||||
- run: DEVICE_NAME='iPhone XR' node run-tests.js -c 1 test/production/prerender-prefetch/index.test.ts
|
||||
if: ${{needs.build.outputs.docsChange == 'nope'}}
|
||||
|
||||
testSafariOld:
|
||||
|
|
|
@ -48,6 +48,11 @@ type InternalLinkProps = {
|
|||
*/
|
||||
onMouseEnter?: (e: any) => void
|
||||
// e: any because as it would otherwise overlap with existing types
|
||||
/**
|
||||
* requires experimental.newNextLinkBehavior
|
||||
*/
|
||||
onTouchStart?: (e: any) => void
|
||||
// e: any because as it would otherwise overlap with existing types
|
||||
/**
|
||||
* requires experimental.newNextLinkBehavior
|
||||
*/
|
||||
|
@ -215,6 +220,7 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
|
|||
locale: true,
|
||||
onClick: true,
|
||||
onMouseEnter: true,
|
||||
onTouchStart: true,
|
||||
legacyBehavior: true,
|
||||
} as const
|
||||
const optionalProps: LinkPropsOptional[] = Object.keys(
|
||||
|
@ -239,7 +245,11 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
|
|||
actual: valType,
|
||||
})
|
||||
}
|
||||
} else if (key === 'onClick' || key === 'onMouseEnter') {
|
||||
} else if (
|
||||
key === 'onClick' ||
|
||||
key === 'onMouseEnter' ||
|
||||
key === 'onTouchStart'
|
||||
) {
|
||||
if (props[key] && valType !== 'function') {
|
||||
throw createPropError({
|
||||
key,
|
||||
|
@ -296,6 +306,7 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
|
|||
locale,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
onTouchStart,
|
||||
legacyBehavior = Boolean(process.env.__NEXT_NEW_LINK_BEHAVIOR) !== true,
|
||||
...restProps
|
||||
} = props
|
||||
|
@ -411,6 +422,7 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
|
|||
}, [as, href, isVisible, locale, p, router])
|
||||
|
||||
const childProps: {
|
||||
onTouchStart: React.TouchEventHandler
|
||||
onMouseEnter: React.MouseEventHandler
|
||||
onClick: React.MouseEventHandler
|
||||
href?: string
|
||||
|
@ -466,6 +478,23 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
|
|||
prefetch(router, href, as, { priority: true })
|
||||
}
|
||||
},
|
||||
onTouchStart: (e: React.TouchEvent<HTMLAnchorElement>) => {
|
||||
if (!legacyBehavior && typeof onTouchStart === 'function') {
|
||||
onTouchStart(e)
|
||||
}
|
||||
|
||||
if (
|
||||
legacyBehavior &&
|
||||
child.props &&
|
||||
typeof child.props.onTouchStart === 'function'
|
||||
) {
|
||||
child.props.onTouchStart(e)
|
||||
}
|
||||
|
||||
if (isLocalURL(href)) {
|
||||
prefetch(router, href, as, { priority: true })
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// If child is an <a> tag and doesn't have a href attribute, or if the 'passHref' property is
|
||||
|
|
|
@ -750,7 +750,7 @@ describe('Production Usage', () => {
|
|||
.elementByCss('a')
|
||||
.click()
|
||||
.waitForElementByCss('.about-page')
|
||||
.elementByCss('div')
|
||||
.elementByCss('.about-page')
|
||||
.text()
|
||||
|
||||
expect(text).toBe('About Page')
|
||||
|
|
|
@ -31,6 +31,9 @@ export class BrowserInterface {
|
|||
elementById(selector: string): BrowserInterface {
|
||||
return this
|
||||
}
|
||||
touchStart(): BrowserInterface {
|
||||
return this
|
||||
}
|
||||
click(opts?: { modifierKey?: boolean }): BrowserInterface {
|
||||
return this
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
BrowserContext,
|
||||
Page,
|
||||
ElementHandle,
|
||||
devices,
|
||||
} from 'playwright-chromium'
|
||||
import path from 'path'
|
||||
|
||||
|
@ -49,6 +50,17 @@ class Playwright extends BrowserInterface {
|
|||
async setup(browserName: string, locale?: string) {
|
||||
if (browser) return
|
||||
const headless = !!process.env.HEADLESS
|
||||
let device
|
||||
|
||||
if (process.env.DEVICE_NAME) {
|
||||
device = devices[process.env.DEVICE_NAME]
|
||||
|
||||
if (!device) {
|
||||
throw new Error(
|
||||
`Invalid playwright device name ${process.env.DEVICE_NAME}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (browserName === 'safari') {
|
||||
browser = await webkit.launch({ headless })
|
||||
|
@ -57,7 +69,7 @@ class Playwright extends BrowserInterface {
|
|||
} else {
|
||||
browser = await chromium.launch({ headless, devtools: !headless })
|
||||
}
|
||||
context = await browser.newContext({ locale })
|
||||
context = await browser.newContext({ locale, ...device })
|
||||
}
|
||||
|
||||
async get(url: string): Promise<void> {
|
||||
|
@ -284,6 +296,12 @@ class Playwright extends BrowserInterface {
|
|||
})
|
||||
}
|
||||
|
||||
touchStart() {
|
||||
return this.chain((el: ElementHandle) => {
|
||||
return el.dispatchEvent('touchstart').then(() => el)
|
||||
})
|
||||
}
|
||||
|
||||
elementsByCss(sel) {
|
||||
return this.chain(() =>
|
||||
page.$$(sel).then((els) => {
|
||||
|
|
|
@ -134,43 +134,83 @@ describe('Prerender prefetch', () => {
|
|||
expect(isNaN(newTime)).toBe(false)
|
||||
})
|
||||
|
||||
it('should attempt cache update on link hover', async () => {
|
||||
const browser = await webdriver(next.url, '/')
|
||||
const timeRes = await fetchViaHTTP(
|
||||
next.url,
|
||||
`/_next/data/${next.buildId}/blog/first.json`,
|
||||
undefined,
|
||||
{
|
||||
headers: {
|
||||
purpose: 'prefetch',
|
||||
},
|
||||
}
|
||||
)
|
||||
const startTime = (await timeRes.json()).pageProps.now
|
||||
if (process.env.DEVICE_NAME) {
|
||||
it('should attempt cache update on touchstart', async () => {
|
||||
const browser = await webdriver(next.url, '/')
|
||||
const timeRes = await fetchViaHTTP(
|
||||
next.url,
|
||||
`/_next/data/${next.buildId}/blog/first.json`,
|
||||
undefined,
|
||||
{
|
||||
headers: {
|
||||
purpose: 'prefetch',
|
||||
},
|
||||
}
|
||||
)
|
||||
const startTime = (await timeRes.json()).pageProps.now
|
||||
|
||||
// ensure stale data is used by default
|
||||
await browser.elementByCss('#to-blog-first').click()
|
||||
await check(() => browser.elementByCss('#page').text(), 'blog/[slug]')
|
||||
// ensure stale data is used by default
|
||||
await browser.elementByCss('#to-blog-first').click()
|
||||
await check(() => browser.elementByCss('#page').text(), 'blog/[slug]')
|
||||
|
||||
expect(JSON.parse(await browser.elementByCss('#props').text()).now).toBe(
|
||||
startTime
|
||||
)
|
||||
await browser.back().waitForElementByCss('#to-blog-first')
|
||||
const requests = []
|
||||
expect(JSON.parse(await browser.elementByCss('#props').text()).now).toBe(
|
||||
startTime
|
||||
)
|
||||
await browser.back().waitForElementByCss('#to-blog-first')
|
||||
const requests = []
|
||||
|
||||
browser.on('request', (req) => {
|
||||
requests.push(req.url())
|
||||
browser.on('request', (req) => {
|
||||
requests.push(req.url())
|
||||
})
|
||||
|
||||
// now trigger cache update and navigate again
|
||||
await check(async () => {
|
||||
await browser.elementByCss('#to-blog-second').touchStart()
|
||||
await browser.elementByCss('#to-blog-first').touchStart()
|
||||
return requests.some((url) => url.includes('/blog/first.json'))
|
||||
? 'success'
|
||||
: requests
|
||||
}, 'success')
|
||||
})
|
||||
} else {
|
||||
it('should attempt cache update on link hover', async () => {
|
||||
const browser = await webdriver(next.url, '/')
|
||||
const timeRes = await fetchViaHTTP(
|
||||
next.url,
|
||||
`/_next/data/${next.buildId}/blog/first.json`,
|
||||
undefined,
|
||||
{
|
||||
headers: {
|
||||
purpose: 'prefetch',
|
||||
},
|
||||
}
|
||||
)
|
||||
const startTime = (await timeRes.json()).pageProps.now
|
||||
|
||||
// now trigger cache update and navigate again
|
||||
await check(async () => {
|
||||
await browser.elementByCss('#to-blog-second').moveTo()
|
||||
await browser.elementByCss('#to-blog-first').moveTo()
|
||||
return requests.some((url) => url.includes('/blog/first.json'))
|
||||
? 'success'
|
||||
: requests
|
||||
}, 'success')
|
||||
})
|
||||
// ensure stale data is used by default
|
||||
await browser.elementByCss('#to-blog-first').click()
|
||||
await check(() => browser.elementByCss('#page').text(), 'blog/[slug]')
|
||||
|
||||
expect(JSON.parse(await browser.elementByCss('#props').text()).now).toBe(
|
||||
startTime
|
||||
)
|
||||
await browser.back().waitForElementByCss('#to-blog-first')
|
||||
const requests = []
|
||||
|
||||
browser.on('request', (req) => {
|
||||
requests.push(req.url())
|
||||
})
|
||||
|
||||
// now trigger cache update and navigate again
|
||||
await check(async () => {
|
||||
await browser.elementByCss('#to-blog-second').moveTo()
|
||||
await browser.elementByCss('#to-blog-first').moveTo()
|
||||
return requests.some((url) => url.includes('/blog/first.json'))
|
||||
? 'success'
|
||||
: requests
|
||||
}, 'success')
|
||||
})
|
||||
}
|
||||
|
||||
it('should handle failed data fetch and empty cache correctly', async () => {
|
||||
const browser = await webdriver(next.url, '/')
|
||||
|
|
Loading…
Reference in a new issue