diff --git a/packages/next/client/link.tsx b/packages/next/client/link.tsx index 5477b7e4867b..d32089a71b19 100644 --- a/packages/next/client/link.tsx +++ b/packages/next/client/link.tsx @@ -8,7 +8,10 @@ import { } from '../shared/lib/router/router' import { addLocale } from './add-locale' import { RouterContext } from '../shared/lib/router-context' -import { AppRouterContext } from '../shared/lib/app-router-context' +import { + AppRouterContext, + AppRouterInstance, +} from '../shared/lib/app-router-context' import { useIntersection } from './use-intersection' import { getDomainLocale } from './get-domain-locale' import { addBasePath } from './add-base-path' @@ -28,6 +31,11 @@ type InternalLinkProps = { href: Url as?: Url replace?: boolean + + /** + * TODO-APP + */ + soft?: boolean scroll?: boolean shallow?: boolean passHref?: boolean @@ -95,10 +103,11 @@ function isModifiedEvent(event: React.MouseEvent): boolean { function linkClicked( e: React.MouseEvent, - router: NextRouter, + router: NextRouter | AppRouterInstance, href: string, as: string, replace?: boolean, + soft?: boolean, shallow?: boolean, scroll?: boolean, locale?: string | false, @@ -117,12 +126,27 @@ function linkClicked( e.preventDefault() const navigate = () => { - // replace state instead of push if prop is present - router[replace ? 'replace' : 'push'](href, as, { - shallow, - locale, - scroll, - }) + // If the router is an AppRouterInstance, then it'll have `softPush` and + // `softReplace`. + if ('softPush' in router && 'softReplace' in router) { + // If we're doing a soft navigation, use the soft variants of + // replace/push. + const method: keyof AppRouterInstance = soft + ? replace + ? 'softReplace' + : 'softPush' + : replace + ? 'replace' + : 'push' + + router[method](href) + } else { + router[replace ? 'replace' : 'push'](href, as, { + shallow, + locale, + scroll, + }) + } } if (startTransition) { @@ -183,6 +207,7 @@ const Link = React.forwardRef( const optionalPropsGuard: Record = { as: true, replace: true, + soft: true, scroll: true, shallow: true, passHref: true, @@ -224,6 +249,7 @@ const Link = React.forwardRef( } } else if ( key === 'replace' || + key === 'soft' || key === 'scroll' || key === 'shallow' || key === 'passHref' || @@ -264,6 +290,7 @@ const Link = React.forwardRef( prefetch: prefetchProp, passHref, replace, + soft, shallow, scroll, locale, @@ -416,6 +443,7 @@ const Link = React.forwardRef( href, as, replace, + soft, shallow, scroll, locale, diff --git a/test/e2e/app-dir/app/app/catch-all/[...slug]/page.server.js b/test/e2e/app-dir/app/app/catch-all/[...slug]/page.server.js new file mode 100644 index 000000000000..c89883d6ce13 --- /dev/null +++ b/test/e2e/app-dir/app/app/catch-all/[...slug]/page.server.js @@ -0,0 +1,11 @@ +export function getServerSideProps({ params }) { + return { props: { params } } +} + +export default function Page({ params }) { + return ( +

+ hello from /catch-all/{params.slug.join('/')} +

+ ) +} diff --git a/test/e2e/app-dir/app/app/client-with-errors/get-server-side-props/page.client.js b/test/e2e/app-dir/app/app/client-with-errors/get-server-side-props/page.client.js new file mode 100644 index 000000000000..8b4ae46ca94b --- /dev/null +++ b/test/e2e/app-dir/app/app/client-with-errors/get-server-side-props/page.client.js @@ -0,0 +1,7 @@ +export function getServerSideProps() { + return { props: {} } +} + +export default function Page() { + return null +} diff --git a/test/e2e/app-dir/app/app/client-with-errors/get-static-props/page.client.js b/test/e2e/app-dir/app/app/client-with-errors/get-static-props/page.client.js new file mode 100644 index 000000000000..5acfaee644a8 --- /dev/null +++ b/test/e2e/app-dir/app/app/client-with-errors/get-static-props/page.client.js @@ -0,0 +1,7 @@ +export function getStaticProps() { + return { props: {} } +} + +export default function Page() { + return null +} diff --git a/test/e2e/app-dir/app/app/dashboard/page.server.js b/test/e2e/app-dir/app/app/dashboard/page.server.js index 49c95abfbf36..5c7a89cc10b6 100644 --- a/test/e2e/app-dir/app/app/dashboard/page.server.js +++ b/test/e2e/app-dir/app/app/dashboard/page.server.js @@ -2,7 +2,9 @@ import ClientComp from './client-comp.client' export default function DashboardPage(props) { return ( <> -

hello from app/dashboard

+

+ hello from app/dashboard +

this is green

diff --git a/test/e2e/app-dir/app/app/hooks/use-cookies/client/page.client.js b/test/e2e/app-dir/app/app/hooks/use-cookies/client/page.client.js new file mode 100644 index 000000000000..36e5fbb96f9f --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-cookies/client/page.client.js @@ -0,0 +1,8 @@ +import { useCookies } from 'next/dist/client/components/hooks-server' + +export default function Page() { + // This should throw an error. + useCookies() + + return null +} diff --git a/test/e2e/app-dir/app/app/hooks/use-cookies/page.server.js b/test/e2e/app-dir/app/app/hooks/use-cookies/page.server.js new file mode 100644 index 000000000000..dd2efcccd7d5 --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-cookies/page.server.js @@ -0,0 +1,19 @@ +import { useCookies } from 'next/dist/client/components/hooks-server' + +export default function Page() { + const cookies = useCookies() + + const hasCookie = + 'use-cookies' in cookies && cookies['use-cookies'] === 'value' + + return ( + <> +

hello from /hooks/use-cookies

+ {hasCookie ? ( + + ) : ( + + )} + + ) +} diff --git a/test/e2e/app-dir/app/app/hooks/use-headers/client/page.client.js b/test/e2e/app-dir/app/app/hooks/use-headers/client/page.client.js new file mode 100644 index 000000000000..9fb9b875af8a --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-headers/client/page.client.js @@ -0,0 +1,8 @@ +import { useHeaders } from 'next/dist/client/components/hooks-server' + +export default function Page() { + // This should throw an error. + useHeaders() + + return null +} diff --git a/test/e2e/app-dir/app/app/hooks/use-headers/page.server.js b/test/e2e/app-dir/app/app/hooks/use-headers/page.server.js new file mode 100644 index 000000000000..2c73997ad4da --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-headers/page.server.js @@ -0,0 +1,22 @@ +import { useHeaders } from 'next/dist/client/components/hooks-server' + +export default function Page() { + const headers = useHeaders() + + const hasHeader = + 'x-use-headers' in headers && headers['x-use-headers'] === 'value' + + return ( + <> +

hello from /hooks/use-headers

+ {hasHeader ? ( +

Has x-use-headers header

+ ) : ( +

Does not have x-use-headers header

+ )} + {'referer' in headers && headers['referer'] && ( +

Has referer header

+ )} + + ) +} diff --git a/test/e2e/app-dir/app/app/hooks/use-layout-segments/server/page.server.js b/test/e2e/app-dir/app/app/hooks/use-layout-segments/server/page.server.js new file mode 100644 index 000000000000..24902634d214 --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-layout-segments/server/page.server.js @@ -0,0 +1,8 @@ +import { useLayoutSegments } from 'next/dist/client/components/hooks-client' + +export default function Page() { + // This should throw an error. + useLayoutSegments() + + return null +} diff --git a/test/e2e/app-dir/app/app/hooks/use-params/server/page.server.js b/test/e2e/app-dir/app/app/hooks/use-params/server/page.server.js new file mode 100644 index 000000000000..c3f5252df45f --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-params/server/page.server.js @@ -0,0 +1,8 @@ +import { useParams } from 'next/dist/client/components/hooks-client' + +export default function Page() { + // This should throw an error. + useParams() + + return null +} diff --git a/test/e2e/app-dir/app/app/hooks/use-pathname/page.client.js b/test/e2e/app-dir/app/app/hooks/use-pathname/page.client.js new file mode 100644 index 000000000000..8aa409b3dd0a --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-pathname/page.client.js @@ -0,0 +1,13 @@ +import { usePathname } from 'next/dist/client/components/hooks-client' + +export default function Page() { + const pathname = usePathname() + + return ( + <> +

+ hello from /hooks/use-pathname +

+ + ) +} diff --git a/test/e2e/app-dir/app/app/hooks/use-pathname/server/page.server.js b/test/e2e/app-dir/app/app/hooks/use-pathname/server/page.server.js new file mode 100644 index 000000000000..9c4c9543406a --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-pathname/server/page.server.js @@ -0,0 +1,8 @@ +import { usePathname } from 'next/dist/client/components/hooks-client' + +export default function Page() { + // This should throw an error. + usePathname() + + return null +} diff --git a/test/e2e/app-dir/app/app/hooks/use-preview-data/client/page.client.js b/test/e2e/app-dir/app/app/hooks/use-preview-data/client/page.client.js new file mode 100644 index 000000000000..094c66773e73 --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-preview-data/client/page.client.js @@ -0,0 +1,8 @@ +import { usePreviewData } from 'next/dist/client/components/hooks-server' + +export default function Page() { + // This should throw an error. + usePreviewData() + + return null +} diff --git a/test/e2e/app-dir/app/app/hooks/use-preview-data/page.server.js b/test/e2e/app-dir/app/app/hooks/use-preview-data/page.server.js new file mode 100644 index 000000000000..afdcad1b2be1 --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-preview-data/page.server.js @@ -0,0 +1,18 @@ +import { usePreviewData } from 'next/dist/client/components/hooks-server' + +export default function Page() { + const data = usePreviewData() + + const hasData = !!data && data.key === 'value' + + return ( + <> +

hello from /hooks/use-preview-data

+ {hasData ? ( +

Has preview data

+ ) : ( +

Does not have preview data

+ )} + + ) +} diff --git a/test/e2e/app-dir/app/app/hooks/use-router/page.client.js b/test/e2e/app-dir/app/app/hooks/use-router/page.client.js new file mode 100644 index 000000000000..844c53fe1e4f --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-router/page.client.js @@ -0,0 +1,17 @@ +import { useRouter } from 'next/dist/client/components/hooks-client' + +export default function Page() { + const router = useRouter() + + return ( + <> +

hello from /hooks/use-router

+ + + ) +} diff --git a/test/e2e/app-dir/app/app/hooks/use-router/server/page.server.js b/test/e2e/app-dir/app/app/hooks/use-router/server/page.server.js new file mode 100644 index 000000000000..ca3f10a333cb --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-router/server/page.server.js @@ -0,0 +1,8 @@ +import { useRouter } from 'next/dist/client/components/hooks-client' + +export default function Page() { + // This should throw an error. + useRouter() + + return null +} diff --git a/test/e2e/app-dir/app/app/hooks/use-router/sub-page/page.client.js b/test/e2e/app-dir/app/app/hooks/use-router/sub-page/page.client.js new file mode 100644 index 000000000000..14c197c67f46 --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-router/sub-page/page.client.js @@ -0,0 +1,3 @@ +export default function Page() { + return

hello from /hooks/use-router/sub-page

+} diff --git a/test/e2e/app-dir/app/app/hooks/use-search-params/page.client.js b/test/e2e/app-dir/app/app/hooks/use-search-params/page.client.js new file mode 100644 index 000000000000..f16caf12cf84 --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-search-params/page.client.js @@ -0,0 +1,19 @@ +import { useSearchParams } from 'next/dist/client/components/hooks-client' + +export default function Page() { + const params = useSearchParams() + + return ( + <> +

+ hello from /hooks/use-search-params +

+ + ) +} diff --git a/test/e2e/app-dir/app/app/hooks/use-search-params/server/page.server.js b/test/e2e/app-dir/app/app/hooks/use-search-params/server/page.server.js new file mode 100644 index 000000000000..3468f385f6a6 --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-search-params/server/page.server.js @@ -0,0 +1,8 @@ +import { useSearchParams } from 'next/dist/client/components/hooks-client' + +export default function Page() { + // This should throw an error. + useSearchParams() + + return null +} diff --git a/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/server/page.server.js b/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/server/page.server.js new file mode 100644 index 000000000000..4f9a6f42af71 --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/server/page.server.js @@ -0,0 +1,8 @@ +import { useSelectedLayoutSegment } from 'next/dist/client/components/hooks-client' + +export default function Page() { + // This should throw an error. + useSelectedLayoutSegment() + + return null +} diff --git a/test/e2e/app-dir/app/app/link-hard-push/page.server.js b/test/e2e/app-dir/app/app/link-hard-push/page.server.js new file mode 100644 index 000000000000..049e5be9c49c --- /dev/null +++ b/test/e2e/app-dir/app/app/link-hard-push/page.server.js @@ -0,0 +1,9 @@ +import Link from 'next/link' + +export default function Page() { + return ( + + With ID + + ) +} diff --git a/test/e2e/app-dir/app/app/link-hard-replace/page.server.js b/test/e2e/app-dir/app/app/link-hard-replace/page.server.js new file mode 100644 index 000000000000..340baa107e37 --- /dev/null +++ b/test/e2e/app-dir/app/app/link-hard-replace/page.server.js @@ -0,0 +1,16 @@ +import { nanoid } from 'nanoid' +import Link from 'next/link' + +export default function Page() { + return ( + <> +

{nanoid()}

+ + Self Link + + + Subpage + + + ) +} diff --git a/test/e2e/app-dir/app/app/link-hard-replace/subpage/page.server.js b/test/e2e/app-dir/app/app/link-hard-replace/subpage/page.server.js new file mode 100644 index 000000000000..cd586543ab75 --- /dev/null +++ b/test/e2e/app-dir/app/app/link-hard-replace/subpage/page.server.js @@ -0,0 +1,9 @@ +import Link from 'next/link' + +export default function Page() { + return ( + + Self Link + + ) +} diff --git a/test/e2e/app-dir/app/app/link-soft-push/page.server.js b/test/e2e/app-dir/app/app/link-soft-push/page.server.js new file mode 100644 index 000000000000..3f5d6bde9ebf --- /dev/null +++ b/test/e2e/app-dir/app/app/link-soft-push/page.server.js @@ -0,0 +1,9 @@ +import Link from 'next/link' + +export default function Page() { + return ( + + With ID + + ) +} diff --git a/test/e2e/app-dir/app/app/link-soft-replace/page.server.js b/test/e2e/app-dir/app/app/link-soft-replace/page.server.js new file mode 100644 index 000000000000..4558d664be32 --- /dev/null +++ b/test/e2e/app-dir/app/app/link-soft-replace/page.server.js @@ -0,0 +1,16 @@ +import { nanoid } from 'nanoid' +import Link from 'next/link' + +export default function Page() { + return ( + <> +

{nanoid()}

+ + Self Link + + + Subpage + + + ) +} diff --git a/test/e2e/app-dir/app/app/link-soft-replace/subpage/page.server.js b/test/e2e/app-dir/app/app/link-soft-replace/subpage/page.server.js new file mode 100644 index 000000000000..971f2843ed74 --- /dev/null +++ b/test/e2e/app-dir/app/app/link-soft-replace/subpage/page.server.js @@ -0,0 +1,9 @@ +import Link from 'next/link' + +export default function Page() { + return ( + + Self Link + + ) +} diff --git a/test/e2e/app-dir/app/app/navigation/page.server.js b/test/e2e/app-dir/app/app/navigation/page.server.js new file mode 100644 index 000000000000..1e53433733bb --- /dev/null +++ b/test/e2e/app-dir/app/app/navigation/page.server.js @@ -0,0 +1,17 @@ +import { nanoid } from 'nanoid' +import Link from 'next/link' + +export default function Page() { + return ( + <> +

{nanoid()}

+

hello from /navigation

+ + useCookies + + + useHeaders + + + ) +} diff --git a/test/e2e/app-dir/app/app/optional-catch-all/[[...slug]]/page.server.js b/test/e2e/app-dir/app/app/optional-catch-all/[[...slug]]/page.server.js new file mode 100644 index 000000000000..3c6b1f8b1106 --- /dev/null +++ b/test/e2e/app-dir/app/app/optional-catch-all/[[...slug]]/page.server.js @@ -0,0 +1,11 @@ +export function getServerSideProps({ params }) { + return { props: { params } } +} + +export default function Page({ params }) { + return ( +

+ hello from /optional-catch-all/{params.slug?.join('/')} +

+ ) +} diff --git a/test/e2e/app-dir/app/app/pages-linking/page.server.js b/test/e2e/app-dir/app/app/pages-linking/page.server.js new file mode 100644 index 000000000000..19b7cc4b861b --- /dev/null +++ b/test/e2e/app-dir/app/app/pages-linking/page.server.js @@ -0,0 +1,9 @@ +import Link from 'next/link' + +export default function Page(props) { + return ( + + To Pages Page + + ) +} diff --git a/test/e2e/app-dir/app/app/param-and-query/[slug]/page.client.js b/test/e2e/app-dir/app/app/param-and-query/[slug]/page.client.js new file mode 100644 index 000000000000..cb2b99034594 --- /dev/null +++ b/test/e2e/app-dir/app/app/param-and-query/[slug]/page.client.js @@ -0,0 +1,7 @@ +export default function Page({ params, query }) { + return ( +

+ hello from /param-and-query/{params.slug}?slug={query.slug} +

+ ) +} diff --git a/test/e2e/app-dir/app/app/rewrites/page.js b/test/e2e/app-dir/app/app/rewrites/page.js new file mode 100644 index 000000000000..ae97d2af2cbd --- /dev/null +++ b/test/e2e/app-dir/app/app/rewrites/page.js @@ -0,0 +1,9 @@ +import Link from 'next/link' + +export default function Page() { + return ( + + To Dashboard Rewritten + + ) +} diff --git a/test/e2e/app-dir/app/app/same-layout/first/page.server.js b/test/e2e/app-dir/app/app/same-layout/first/page.server.js new file mode 100644 index 000000000000..bf4ba9e2455b --- /dev/null +++ b/test/e2e/app-dir/app/app/same-layout/first/page.server.js @@ -0,0 +1,12 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> +

hello from same-layout/first

+ + To Second + + + ) +} diff --git a/test/e2e/app-dir/app/app/same-layout/layout.server.js b/test/e2e/app-dir/app/app/same-layout/layout.server.js new file mode 100644 index 000000000000..42734ad7255d --- /dev/null +++ b/test/e2e/app-dir/app/app/same-layout/layout.server.js @@ -0,0 +1,10 @@ +import { nanoid } from 'nanoid' + +export default function Layout({ children }) { + return ( + <> +

{nanoid()}

+
{children}
+ + ) +} diff --git a/test/e2e/app-dir/app/app/same-layout/second/page.server.js b/test/e2e/app-dir/app/app/same-layout/second/page.server.js new file mode 100644 index 000000000000..9d981dcb708a --- /dev/null +++ b/test/e2e/app-dir/app/app/same-layout/second/page.server.js @@ -0,0 +1,12 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> +

hello from same-layout/second

+ + To First + + + ) +} diff --git a/test/e2e/app-dir/app/app/with-id/page.server.js b/test/e2e/app-dir/app/app/with-id/page.server.js new file mode 100644 index 000000000000..07beecc4203f --- /dev/null +++ b/test/e2e/app-dir/app/app/with-id/page.server.js @@ -0,0 +1,13 @@ +import Link from 'next/link' +import { nanoid } from 'nanoid' + +export default function Page() { + return ( + <> +

{nanoid()}

+ + To Navigation + + + ) +} diff --git a/test/e2e/app-dir/app/pages/api/preview.js b/test/e2e/app-dir/app/pages/api/preview.js new file mode 100644 index 000000000000..f926b6f2319a --- /dev/null +++ b/test/e2e/app-dir/app/pages/api/preview.js @@ -0,0 +1,4 @@ +export default function handler(req, res) { + res.setPreviewData({ key: 'value' }) + res.send(200).end() +} diff --git a/test/e2e/app-dir/app/pages/app-linking.js b/test/e2e/app-dir/app/pages/app-linking.js new file mode 100644 index 000000000000..0a8e0289afe4 --- /dev/null +++ b/test/e2e/app-dir/app/pages/app-linking.js @@ -0,0 +1,9 @@ +import Link from 'next/link' + +export default function Page(props) { + return ( + + To App Page + + ) +} diff --git a/test/e2e/app-dir/index.test.ts b/test/e2e/app-dir/index.test.ts index 30cb129cc8c9..757d5e6a10b2 100644 --- a/test/e2e/app-dir/index.test.ts +++ b/test/e2e/app-dir/index.test.ts @@ -206,6 +206,245 @@ describe('app dir', () => { expect(html).toContain('hello from app/partial-match-[id]. ID is: 123') }) + it('should support rewrites', async () => { + const html = await renderViaHTTP(next.url, '/rewritten-to-dashboard') + expect(html).toContain('hello from app/dashboard') + }) + + it('should not rerender layout when navigating between routes in the same layout', async () => { + const browser = await webdriver(next.url, '/same-layout/first') + + try { + // Get the render id from the dom and click the first link. + const firstRenderID = await browser.elementById('render-id').text() + await browser.elementById('link').click() + await browser.waitForElementByCss('#second-page') + + // Get the render id from the dom again, it should be the same! + const secondRenderID = await browser.elementById('render-id').text() + expect(secondRenderID).toBe(firstRenderID) + + // Navigate back to the first page again by clicking the link. + await browser.elementById('link').click() + await browser.waitForElementByCss('#first-page') + + // Get the render id from the dom again, it should be the same! + const thirdRenderID = await browser.elementById('render-id').text() + expect(thirdRenderID).toBe(firstRenderID) + } finally { + await browser.close() + } + }) + + describe('', () => { + it('should hard push', async () => { + const browser = await webdriver(next.url, '/link-hard-push') + + try { + // Click the link on the page, and verify that the history entry was + // added. + expect(await browser.eval('window.history.length')).toBe(2) + await browser.elementById('link').click() + await browser.waitForElementByCss('#render-id') + expect(await browser.eval('window.history.length')).toBe(3) + + // Get the id on the rendered page. + const firstID = await browser.elementById('render-id').text() + + // Go back, and redo the navigation by clicking the link. + await browser.back() + await browser.elementById('link').click() + await browser.waitForElementByCss('#render-id') + + // Get the id again, and compare, they should not be the same. + const secondID = await browser.elementById('render-id').text() + expect(secondID).not.toBe(firstID) + } finally { + await browser.close() + } + }) + + it('should hard replace', async () => { + const browser = await webdriver(next.url, '/link-hard-replace') + + try { + // Get the render ID so we can compare it. + const firstID = await browser.elementById('render-id').text() + + // Click the link on the page, and verify that the history entry was NOT + // added. + expect(await browser.eval('window.history.length')).toBe(2) + await browser.elementById('self-link').click() + await browser.waitForElementByCss('#render-id') + expect(await browser.eval('window.history.length')).toBe(2) + + // Get the date again, and compare, they should not be the same. + const secondID = await browser.elementById('render-id').text() + expect(secondID).not.toBe(firstID) + + // Navigate to the subpage, verify that the history entry was NOT added. + await browser.elementById('subpage-link').click() + await browser.waitForElementByCss('#back-link') + expect(await browser.eval('window.history.length')).toBe(2) + + // Navigate back again, verify that the history entry was NOT added. + await browser.elementById('back-link').click() + await browser.waitForElementByCss('#render-id') + expect(await browser.eval('window.history.length')).toBe(2) + + // Get the date again, and compare, they should not be the same. + const thirdID = await browser.elementById('render-id').text() + expect(thirdID).not.toBe(secondID) + } finally { + await browser.close() + } + }) + + it('should soft push', async () => { + const browser = await webdriver(next.url, '/link-soft-push') + + try { + // Click the link on the page, and verify that the history entry was + // added. + expect(await browser.eval('window.history.length')).toBe(2) + await browser.elementById('link').click() + await browser.waitForElementByCss('#render-id') + expect(await browser.eval('window.history.length')).toBe(3) + + // Get the id on the rendered page. + const firstID = await browser.elementById('render-id').text() + + // Go back, and redo the navigation by clicking the link. + await browser.back() + await browser.elementById('link').click() + + // Get the date again, and compare, they should be the same. + const secondID = await browser.elementById('render-id').text() + expect(firstID).toBe(secondID) + } finally { + await browser.close() + } + }) + + it('should soft replace', async () => { + const browser = await webdriver(next.url, '/link-soft-replace') + + try { + // Get the render ID so we can compare it. + const firstID = await browser.elementById('render-id').text() + + // Click the link on the page, and verify that the history entry was NOT + // added. + expect(await browser.eval('window.history.length')).toBe(2) + await browser.elementById('self-link').click() + await browser.waitForElementByCss('#render-id') + expect(await browser.eval('window.history.length')).toBe(2) + + // Get the id on the rendered page. + const secondID = await browser.elementById('render-id').text() + expect(secondID).toBe(firstID) + + // Navigate to the subpage, verify that the history entry was NOT added. + await browser.elementById('subpage-link').click() + await browser.waitForElementByCss('#back-link') + expect(await browser.eval('window.history.length')).toBe(2) + + // Navigate back again, verify that the history entry was NOT added. + await browser.elementById('back-link').click() + await browser.waitForElementByCss('#render-id') + expect(await browser.eval('window.history.length')).toBe(2) + + // Get the date again, and compare, they should be the same. + const thirdID = await browser.elementById('render-id').text() + expect(thirdID).toBe(firstID) + } finally { + await browser.close() + } + }) + + it('should be soft for back navigation', async () => { + const browser = await webdriver(next.url, '/with-id') + + try { + // Get the id on the rendered page. + const firstID = await browser.elementById('render-id').text() + + // Click the link, and go back. + await browser.elementById('link').click() + await browser.waitForElementByCss('#from-navigation') + await browser.back() + + // Get the date again, and compare, they should be the same. + const secondID = await browser.elementById('render-id').text() + expect(firstID).toBe(secondID) + } finally { + await browser.close() + } + }) + + it('should be soft for forward navigation', async () => { + const browser = await webdriver(next.url, '/with-id') + + try { + // Click the link. + await browser.elementById('link').click() + await browser.waitForElementByCss('#from-navigation') + + // Get the id on the rendered page. + const firstID = await browser.elementById('render-id').text() + + // Go back, then forward. + await browser.back() + await browser.forward() + + // Get the date again, and compare, they should be the same. + const secondID = await browser.elementById('render-id').text() + expect(firstID).toBe(secondID) + } finally { + await browser.close() + } + }) + + it('should respect rewrites', async () => { + const browser = await webdriver(next.url, '/rewrites') + + try { + // Click the link. + await browser.elementById('link').click() + await browser.waitForElementByCss('#from-dashboard') + + // Check to see that we were rewritten and not redirected. + const pathname = await browser.eval('window.location.pathname') + expect(pathname).toBe('/rewritten-to-dashboard') + + // Check to see that the page we navigated to is in fact the dashboard. + const html = await browser.eval( + 'window.document.documentElement.innerText' + ) + expect(html).toContain('hello from app/dashboard') + } finally { + await browser.close() + } + }) + + // TODO-APP: should enable when implemented + it.skip('should allow linking from app page to pages page', async () => { + const browser = await webdriver(next.url, '/pages-linking') + + try { + // Click the link. + await browser.elementById('app-link').click() + await browser.waitForElementByCss('#pages-link') + + // Click the other link. + await browser.elementById('pages-link').click() + await browser.waitForElementByCss('#app-link') + } finally { + await browser.close() + } + }) + }) + describe('server components', () => { // TODO: why is this not servable but /dashboard+rootonly/hello.server.js // should be? Seems like they both either should be servable or not @@ -275,6 +514,39 @@ describe('app dir', () => { }) }) + describe('catch-all routes', () => { + it('should handle optional segments', async () => { + const params = ['this', 'is', 'a', 'test'] + const route = params.join('/') + const html = await renderViaHTTP( + next.url, + `/optional-catch-all/${route}` + ) + const $ = cheerio.load(html) + expect($('#text').attr('data-params')).toBe(route) + }) + + it('should handle optional segments root', async () => { + const html = await renderViaHTTP(next.url, `/optional-catch-all`) + const $ = cheerio.load(html) + expect($('#text').attr('data-params')).toBe('') + }) + + it('should handle required segments', async () => { + const params = ['this', 'is', 'a', 'test'] + const route = params.join('/') + const html = await renderViaHTTP(next.url, `/catch-all/${route}`) + const $ = cheerio.load(html) + expect($('#text').attr('data-params')).toBe(route) + }) + + it('should handle required segments root as not found', async () => { + const res = await fetchViaHTTP(next.url, `/catch-all`) + expect(res.status).toBe(404) + expect(await res.text()).toContain('This page could not be found') + }) + }) + describe('should serve client component', () => { it('should serve server-side', async () => { const html = await renderViaHTTP(next.url, '/client-component-route') @@ -403,6 +675,296 @@ describe('app dir', () => { ) }) }) + + describe('hooks', () => { + describe('useCookies', () => { + it('should retrive cookies in a server component', async () => { + const browser = await webdriver(next.url, '/hooks/use-cookies') + + try { + await browser.waitForElementByCss('#does-not-have-cookie') + browser.addCookie({ name: 'use-cookies', value: 'value' }) + browser.refresh() + + await browser.waitForElementByCss('#has-cookie') + browser.deleteCookies() + browser.refresh() + + await browser.waitForElementByCss('#does-not-have-cookie') + } finally { + await browser.close() + } + }) + + it('should access cookies on navigation', async () => { + const browser = await webdriver(next.url, '/navigation') + + try { + // Click the cookies link to verify it can't see the cookie that's + // not there. + await browser.elementById('use-cookies').click() + await browser.waitForElementByCss('#does-not-have-cookie') + + // Go back and add the cookies. + await browser.back() + await browser.waitForElementByCss('#from-navigation') + browser.addCookie({ name: 'use-cookies', value: 'value' }) + + // Click the cookies link again to see that the cookie can be picked + // up again. + await browser.elementById('use-cookies').click() + await browser.waitForElementByCss('#has-cookie') + + // Go back and remove the cookies. + await browser.back() + await browser.waitForElementByCss('#from-navigation') + browser.deleteCookies() + + // Verify for the last time that after clicking the cookie link + // again, there are no cookies. + await browser.elementById('use-cookies').click() + await browser.waitForElementByCss('#does-not-have-cookie') + } finally { + await browser.close() + } + }) + }) + + describe('useHeaders', () => { + it('should have access to incoming headers in a server component', async () => { + // Check to see that we can't see the header when it's not present. + let html = await renderViaHTTP( + next.url, + '/hooks/use-headers', + {}, + { headers: {} } + ) + let $ = cheerio.load(html) + expect($('#does-not-have-header').length).toBe(1) + expect($('#has-header').length).toBe(0) + + // Check to see that we can see the header when it's present. + html = await renderViaHTTP( + next.url, + '/hooks/use-headers', + {}, + { headers: { 'x-use-headers': 'value' } } + ) + $ = cheerio.load(html) + expect($('#has-header').length).toBe(1) + expect($('#does-not-have-header').length).toBe(0) + }) + + it('should access headers on navigation', async () => { + const browser = await webdriver(next.url, '/navigation') + + try { + await browser.elementById('use-headers').click() + await browser.waitForElementByCss('#has-referer') + } finally { + await browser.close() + } + }) + }) + + describe('usePreviewData', () => { + it('should return no preview data when there is none', async () => { + const browser = await webdriver(next.url, '/hooks/use-preview-data') + + try { + await browser.waitForElementByCss('#does-not-have-preview-data') + } finally { + await browser.close() + } + }) + + it('should return preview data when there is some', async () => { + const browser = await webdriver(next.url, '/api/preview') + + try { + await browser.loadPage(next.url + '/hooks/use-preview-data', { + disableCache: false, + beforePageLoad: null, + }) + await browser.waitForElementByCss('#has-preview-data') + } finally { + await browser.close() + } + }) + }) + + describe('useRouter', () => { + // TODO-APP: should enable when implemented + it.skip('should throw an error when imported', async () => { + const res = await fetchViaHTTP(next.url, '/hooks/use-router/server') + expect(res.status).toBe(500) + expect(await res.text()).toContain('Internal Server Error') + }) + }) + + describe('useParams', () => { + // TODO-APP: should enable when implemented + it.skip('should throw an error when imported', async () => { + const res = await fetchViaHTTP(next.url, '/hooks/use-params/server') + expect(res.status).toBe(500) + expect(await res.text()).toContain('Internal Server Error') + }) + }) + + describe('useSearchParams', () => { + // TODO-APP: should enable when implemented + it.skip('should throw an error when imported', async () => { + const res = await fetchViaHTTP( + next.url, + '/hooks/use-search-params/server' + ) + expect(res.status).toBe(500) + expect(await res.text()).toContain('Internal Server Error') + }) + }) + + describe('usePathname', () => { + // TODO-APP: should enable when implemented + it.skip('should throw an error when imported', async () => { + const res = await fetchViaHTTP(next.url, '/hooks/use-pathname/server') + expect(res.status).toBe(500) + expect(await res.text()).toContain('Internal Server Error') + }) + }) + + describe('useLayoutSegments', () => { + // TODO-APP: should enable when implemented + it.skip('should throw an error when imported', async () => { + const res = await fetchViaHTTP( + next.url, + '/hooks/use-layout-segments/server' + ) + expect(res.status).toBe(500) + expect(await res.text()).toContain('Internal Server Error') + }) + }) + + describe('useSelectedLayoutSegment', () => { + // TODO-APP: should enable when implemented + it.skip('should throw an error when imported', async () => { + const res = await fetchViaHTTP( + next.url, + '/hooks/use-selected-layout-segment/server' + ) + expect(res.status).toBe(500) + expect(await res.text()).toContain('Internal Server Error') + }) + }) + }) + }) + + describe('client components', () => { + describe('hooks', () => { + describe('useCookies', () => { + // TODO-APP: should enable when implemented + it.skip('should throw an error when imported', async () => { + const res = await fetchViaHTTP(next.url, '/hooks/use-cookies/client') + expect(res.status).toBe(500) + expect(await res.text()).toContain('Internal Server Error') + }) + }) + + describe('usePreviewData', () => { + // TODO-APP: should enable when implemented + it.skip('should throw an error when imported', async () => { + const res = await fetchViaHTTP( + next.url, + '/hooks/use-preview-data/client' + ) + expect(res.status).toBe(500) + expect(await res.text()).toContain('Internal Server Error') + }) + }) + + describe('useHeaders', () => { + // TODO-APP: should enable when implemented + it.skip('should throw an error when imported', async () => { + const res = await fetchViaHTTP(next.url, '/hooks/use-headers/client') + expect(res.status).toBe(500) + expect(await res.text()).toContain('Internal Server Error') + }) + }) + + describe('usePathname', () => { + it('should have the correct pathname', async () => { + const html = await renderViaHTTP(next.url, '/hooks/use-pathname') + const $ = cheerio.load(html) + expect($('#pathname').attr('data-pathname')).toBe( + '/hooks/use-pathname' + ) + }) + }) + + describe('useSearchParams', () => { + it('should have the correct search params', async () => { + const html = await renderViaHTTP( + next.url, + '/hooks/use-search-params?first=value&second=other%20value&third' + ) + const $ = cheerio.load(html) + const el = $('#params') + expect(el.attr('data-param-first')).toBe('value') + expect(el.attr('data-param-second')).toBe('other value') + expect(el.attr('data-param-third')).toBe('') + expect(el.attr('data-param-not-real')).toBe('N/A') + }) + }) + + describe('useRouter', () => { + it('should allow access to the router', async () => { + const browser = await webdriver(next.url, '/hooks/use-router') + + try { + // Wait for the page to load, click the button (which uses a method + // on the router) and then wait for the correct page to load. + await browser.waitForElementByCss('#router') + await browser.elementById('button-push').click() + await browser.waitForElementByCss('#router-sub-page') + + // Go back (confirming we did do a hard push), and wait for the + // correct previous page. + await browser.back() + await browser.waitForElementByCss('#router') + } finally { + await browser.close() + } + }) + + it('should have consistent query and params handling', async () => { + const html = await renderViaHTTP( + next.url, + '/param-and-query/params?slug=query' + ) + const $ = cheerio.load(html) + const el = $('#params-and-query') + expect(el.attr('data-params')).toBe('params') + expect(el.attr('data-query')).toBe('query') + }) + }) + }) + + it('should throw an error when getStaticProps is used', async () => { + const res = await fetchViaHTTP( + next.url, + '/client-with-errors/get-static-props' + ) + expect(res.status).toBe(500) + expect(await res.text()).toContain('Internal Server Error') + }) + + it('should throw an error when getServerSideProps is used', async () => { + const res = await fetchViaHTTP( + next.url, + '/client-with-errors/get-server-side-props' + ) + expect(res.status).toBe(500) + expect(await res.text()).toContain('Internal Server Error') + }) }) describe('css support', () => {