next.js/test/e2e/app-dir/sub-shell-generation-middleware/sub-shell-generation-middleware.test.ts
sub-shell-generation-middleware.test.ts258 lines9.3 KB
import { nextTestSetup } from 'e2e-utils'
import * as cheerio from 'cheerio'
import { getCacheHeader, retry } from 'next-test-utils'
import { computeCacheBustingSearchParam } from 'next/dist/shared/lib/router/utils/cache-busting-search-param'

const isAdapterTest = Boolean(process.env.NEXT_ENABLE_ADAPTER)

describe('middleware-static-rewrite', () => {
  const { next, isNextDeploy, isNextDev } = nextTestSetup({
    files: __dirname,
    // The latest changes to support this behavior on deployed infra are available in the adapter,
    // and are not being backported to the CLI
    skipDeployment: !isAdapterTest,
  })

  if (isNextDev) {
    it.skip('skipping dev test', () => {})
    return
  }

  if (process.env.__NEXT_CACHE_COMPONENTS === 'true') {
    // Here we're validating that the correct fallback shell was used for
    // rendering.
    it('should use the correct fallback route', async () => {
      // First try to load a page that'll use the base fallback route with the
      // `/[first]/[second]/[third]` fallback.
      let $ = await next.render$('/first/second/third')

      expect($('[data-slug]').data('slug')).toBe('first/second/third')

      // Get the sentinel value that was generated at build time or runtime.
      expect($('[data-layout="/"]').data('sentinel')).toBe('buildtime')
      expect($('[data-layout="/[first]"]').data('sentinel')).toBe('buildtime')
      expect($('[data-layout="/[first]/[second]"]').data('sentinel')).toBe(
        'buildtime'
      )
      expect(
        $('[data-layout="/[first]/[second]/[third]"]').data('sentinel')
      ).toBe('buildtime')

      // Then we try to load a page that'll use the `/first/second/[third]`
      // fallback.
      $ = await next.render$('/first/second/not-third')

      expect($('[data-slug]').data('slug')).toBe('first/second/not-third')

      expect($('[data-layout="/"]').data('sentinel')).toBe('buildtime')
      expect($('[data-layout="/[first]"]').data('sentinel')).toBe('buildtime')
      expect($('[data-layout="/[first]/[second]"]').data('sentinel')).toBe(
        'buildtime'
      )
      expect(
        $('[data-layout="/[first]/[second]/[third]"]').data('sentinel')
      ).toBe('runtime')

      // Then we try to load a page that'll use the `/first/[second]/[third]`
      $ = await next.render$('/first/not-second/not-third')

      expect($('[data-slug]').data('slug')).toBe('first/not-second/not-third')

      expect($('[data-layout="/"]').data('sentinel')).toBe('buildtime')
      expect($('[data-layout="/[first]"]').data('sentinel')).toBe('buildtime')
      expect($('[data-layout="/[first]/[second]"]').data('sentinel')).toBe(
        'runtime'
      )
      expect(
        $('[data-layout="/[first]/[second]/[third]"]').data('sentinel')
      ).toBe('runtime')

      // Then we try to load a page that'll use the `/[first]/[second]/[third]`
      $ = await next.render$('/not-first/not-second/not-third')

      expect($('[data-slug]').data('slug')).toBe(
        'not-first/not-second/not-third'
      )

      expect($('[data-layout="/"]').data('sentinel')).toBe('buildtime')
      expect($('[data-layout="/[first]"]').data('sentinel')).toBe('runtime')
      expect($('[data-layout="/[first]/[second]"]').data('sentinel')).toBe(
        'runtime'
      )
      expect(
        $('[data-layout="/[first]/[second]/[third]"]').data('sentinel')
      ).toBe('runtime')
    })

    it('should handle middleware rewrites as well', async () => {
      let res = await next.fetch('/not-broken')

      expect(res.status).toBe(200)

      if (isNextDeploy) {
        // We produced a partial fallback shell for rewrite/[slug], so we shouldn't see a cache HIT.
        expect(getCacheHeader(res)).toMatch(/MISS|PRERENDER/)
      } else {
        expect(res.headers.get('x-nextjs-cache')).toBe(null)
      }

      let html = await res.text()
      let $ = cheerio.load(html)

      expect($('[data-layout="/"]').data('sentinel')).toBe('buildtime')
      expect($('[data-layout="/rewrite"]').data('sentinel')).toBe('buildtime')
      expect($('[data-layout="/rewrite/[slug]"]').data('sentinel')).toBe(
        'runtime'
      )

      await retry(async () => {
        res = await next.fetch('/not-broken')

        expect(res.status).toBe(200)
        if (isNextDeploy) {
          expect(getCacheHeader(res)).toBe('HIT')
        } else {
          expect(res.headers.get('x-nextjs-cache')).toBe('HIT')
        }

        html = await res.text()
        $ = cheerio.load(html)

        expect($('[data-rewrite-slug]').data('rewrite-slug')).toBe('not-broken')

        // With partial fallback upgrading, the cached entry upgrades from a
        // fallback shell to a full route render.
        // These assertions are part of the outer retry block because the fallback->route shell upgrade
        // happens in the background. It's possible to see a cache HIT for the fallback shell before it
        // switches to the full route shell.
        expect($('[data-layout="/"]').data('sentinel')).toBe('runtime')
        expect($('[data-layout="/rewrite"]').data('sentinel')).toBe('runtime')
        expect($('[data-layout="/rewrite/[slug]"]').data('sentinel')).toBe(
          'runtime'
        )
      })
    })

    it('should revalidate the overview page without replacing it with a 404', async () => {
      const url = new URL('/my-team', 'http://localhost')
      const rsc = await computeCacheBustingSearchParam(
        '1',
        '/_head',
        undefined,
        undefined
      )

      url.searchParams.set('_rsc', rsc)

      let res = await next.fetch(url.pathname + url.search, {
        headers: {
          Cookie: 'overview-param=grid',
          RSC: '1',
          'Next-Router-Prefetch': '1',
          'Next-Router-Segment-Prefetch': '/_head',
        },
      })

      // A 404 here represents a routing issue that was resolved by an upstream
      // PR in the builder: https://github.com/vercel/vercel/pull/13927
      expect(res.status).toBe(200)

      // Now, let's verify that we both got rewritten to the correct page, and
      // that we're being served the prerender shell.
      expect(res.headers.get('x-nextjs-rewritten-path')).toBe(
        '/my-team/~/overview/grid'
      )
      expect(res.headers.get('x-nextjs-postponed')).toBe('2')
      expect(res.headers.get('x-nextjs-prerender')).toBe('1')

      // Grab the RSC content.
      const rsc1 = await res.text()

      // Grab the title which includes the random number.
      const title1 = rsc1.match(/Grid Page (\d+\.\d+)/)?.[1]
      expect(title1).toBeDefined()

      // Now, let's trigger a revalidation for the page.
      res = await next.fetch(
        '/api?path=/my-team&path=/my-team/~/overview/grid&path=/[first]/~/overview/grid'
      )

      // Now, let's keep polling the prefetch until it's revalidated.
      let rsc2: string
      await retry(async () => {
        res = await next.fetch(url.pathname + url.search, {
          headers: {
            Cookie: 'overview-param=grid',
            RSC: '1',
            'Next-Router-Prefetch': '1',
            'Next-Router-Segment-Prefetch': '/_head',
          },
        })

        // A 404 here represents a routing issue that was resolved by an upstream
        // PR in the builder: https://github.com/vercel/vercel/pull/13927
        expect(res.status).toBe(200)

        // We're expecting that the title has changed, so let's compare that the
        // rsc payload is different.
        rsc2 = await res.text()
        expect(rsc1).not.toBe(rsc2)
      })

      // Now that the revalidation has been completed, let's also verify that
      // it revalidated correctly.
      expect(res.headers.get('x-nextjs-postponed')).toBe('2')
      expect(res.headers.get('x-nextjs-rewritten-path')).toBe(
        '/my-team/~/overview/grid'
      )

      // We expect that the only difference between the two RSC contents is the
      // title.
      const title2 = rsc2.match(/Grid Page (\d+\.\d+)/)?.[1]
      expect(title2).toBeDefined()
      expect(title2).not.toBe(title1)

      // Let's compare the RSC contents, with the titles removed.
      const cleaned1 = rsc1.replace(/Grid Page (\d+\.\d+)/, 'Grid Page')
      const cleaned2 = rsc2.replace(/Grid Page (\d+\.\d+)/, 'Grid Page')
      expect(cleaned1).toBe(cleaned2)
    })
  } else {
    // Here we're validating that there is a static page generated for the
    // rewritten path.
    it('should eventually result in a cache hit', async () => {
      let res = await next.fetch('/not-broken')

      expect(res.status).toBe(200)
      expect(getCacheHeader(res)).toMatch(/MISS|HIT|PRERENDER/)

      let html = await res.text()
      let $ = cheerio.load(html)

      expect($('[data-layout="/"]').data('sentinel')).toBe('runtime')
      expect($('[data-layout="/rewrite"]').data('sentinel')).toBe('runtime')
      expect($('[data-layout="/rewrite/[slug]"]').data('sentinel')).toBe(
        'runtime'
      )

      await retry(async () => {
        res = await next.fetch('/not-broken')

        expect(res.status).toBe(200)
        expect(getCacheHeader(res)).toBe('HIT')
      })

      html = await res.text()
      $ = cheerio.load(html)

      expect($('[data-rewrite-slug]').data('rewrite-slug')).toBe('not-broken')

      expect($('[data-layout="/"]').data('sentinel')).toBe('runtime')
      expect($('[data-layout="/rewrite"]').data('sentinel')).toBe('runtime')
      expect($('[data-layout="/rewrite/[slug]"]').data('sentinel')).toBe(
        'runtime'
      )
    })
  }
})
Quest for Codev2.0.0
/
SIGN IN