next.js/test/e2e/app-dir/interception-dynamic-segment/interception-dynamic-segment.test.ts
interception-dynamic-segment.test.ts419 lines14.3 KB
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
import { Playwright } from 'next-webdriver'
import { createRouterAct } from 'router-act'

describe('interception-dynamic-segment', () => {
  const { next, isNextStart, isNextDev } = nextTestSetup({
    files: __dirname,
  })

  /**
   * Returns true if the given href should already be opened. This allows us to
   * condition on whether to expect any additional network requests.
   */
  async function isAccordionClosed(
    browser: Playwright,
    href: string
  ): Promise<boolean> {
    const selector = `[data-testid="link-accordion"][data-href="${href}"]`

    // Check if the button is already open
    return await browser.hasElementByCss(`${selector} button`)
  }

  /**
   * Helper to navigate via the LinkAccordion component.
   * Scrolls to the accordion, opens it, and clicks the link.
   */
  async function navigate(browser: Playwright, href: string) {
    const selector = `[data-testid="link-accordion"][data-href="${href}"]`

    // Find and scroll to accordion
    const accordion = await browser.elementByCss(selector)
    await accordion.scrollIntoViewIfNeeded()

    // Click the "Open" button, it may already be open, so we don't need to
    // click it again.
    if (await isAccordionClosed(browser, href)) {
      const button = await browser.elementByCss(`${selector} button`)
      await button.click()
    }

    // Click the actual link
    const link = await browser.elementByCss(`${selector} a`)
    await link.click()
  }

  /**
   * Create a browser with router act that will FAIL if any 404s occur during navigation.
   * This is critical because if a 404 occurs, the client will perform MPA navigation
   * (full page reload) which still successfully navigates, hiding the bug.
   */
  async function createBrowserWithRouterAct(url: string) {
    let act: ReturnType<typeof createRouterAct>
    const browser = await next.browser(url, {
      beforePageLoad(page) {
        // DON'T use allowErrorStatusCodes - we want 404s to fail the test
        act = createRouterAct(page)
      },
    })

    return { act: act!, browser }
  }

  it('should work when interception route is paired with a dynamic segment', async () => {
    const browser = await next.browser('/')

    await navigate(browser, '/foo/1')
    await browser.waitForIdleNetwork()

    await retry(async () => {
      expect(await browser.elementById('modal').text()).toContain('intercepted')
    })

    await browser.refresh()
    await browser.waitForIdleNetwork()

    await retry(async () => {
      expect(await browser.elementById('modal').text()).toContain('catch-all')
    })
    await retry(async () => {
      expect(await browser.elementById('children').text()).toContain(
        'not intercepted'
      )
    })
  })

  it('should intercept consistently with back/forward navigation', async () => {
    // Test that the fix works with browser back/forward navigation
    const browser = await next.browser('/')

    // Navigate with interception
    await navigate(browser, '/foo/1')
    await browser.waitForIdleNetwork()

    await retry(async () => {
      expect(await browser.elementById('modal').text()).toContain('intercepted')
    })

    // Go back to root
    await browser.back()
    await browser.waitForIdleNetwork()

    await retry(async () => {
      const url = await browser.url()
      expect(url).toContain('/')
    })

    // Go forward - should show intercepted version
    await browser.forward()
    await browser.waitForIdleNetwork()

    await retry(async () => {
      expect(await browser.elementById('modal').text()).toContain('intercepted')
    })
  })

  it('should intercept multiple times from root', async () => {
    // Test that repeated interception from root works
    const browser = await next.browser('/')

    for (let i = 0; i < 2; i++) {
      await navigate(browser, '/foo/1')
      await browser.waitForIdleNetwork()

      await retry(async () => {
        expect(await browser.elementById('modal').text()).toContain(
          'intercepted'
        )
      })

      await browser.back()
      await browser.waitForIdleNetwork()

      await retry(async () => {
        const url = await browser.url()
        expect(url).toMatch(/\/$/)
      })
    }
  })

  if (isNextStart) {
    it('should correctly prerender segments with generateStaticParams', async () => {
      expect(next.cliOutput).toContain('/generate-static-params/a')
      const res = await next.fetch('/generate-static-params/a')
      expect(res.status).toBe(200)
      expect(res.headers.get('x-nextjs-cache')).toBe('HIT')
    })

    it('should prerender a dynamic intercepted route', async () => {
      if (process.env.__NEXT_CACHE_COMPONENTS === 'true') {
        expect(next.cliOutput).toContain('/(.)[username]/[id]')
        expect(next.cliOutput).toContain('/(.)john/[id]')
      }

      expect(next.cliOutput).toContain('/(.)john/1')
      expect(next.cliOutput).not.toContain('/john/1')
    })
  }

  if (!isNextDev) {
    /**
     * Test Case Validation: Ensure NO 404s occur during interception navigation
     * These tests validate the fix for default.tsx injection with parallel routes.
     * Using createRouterAct WITHOUT allowErrorStatusCodes ensures that any 404
     * response will fail the test, preventing the bug where MPA navigation masks 404s.
     */
    describe('Default.tsx injection validation (no 404s allowed)', () => {
      /**
       * Test Case: Dynamic segment interception route [username]/[id]
       * Validates that intercepted routes with dynamic segments don't return 404
       */
      it('should not render a 404 for the intercepted route with dynamic segments', async () => {
        const { act, browser } = await createBrowserWithRouterAct('/')

        await act(async () => {
          await navigate(browser, '/foo/1')
        })

        await retry(async () => {
          expect(await browser.elementById('modal').text()).toContain(
            'intercepted'
          )
        })
      })
      /**
       * Test Case 1a: Simple interception page (no parallel routes)
       * Structure: @modal/(.)simple-page/page.tsx
       * Expected: Should work WITHOUT null default logic
       * Reason: No parallel routes = no implicit layout = no children slot
       */
      it('should navigate to /simple-page without 404 (no parallel routes)', async () => {
        const { act, browser } = await createBrowserWithRouterAct('/')

        await act(async () => {
          await navigate(browser, '/simple-page')
        })

        await retry(async () => {
          expect(await browser.elementByCss('#modal h3').text()).toContain(
            'Simple interception page'
          )
        })
      })

      /**
       * Test Case 1b: Has page.tsx at interception level
       * Structure: @modal/(.)has-page/page.tsx
       * Expected: Should work WITHOUT default.tsx
       * Reason: page.tsx fills the children slot
       */
      it('should navigate to /has-page without 404 (page fills children)', async () => {
        const { act, browser } = await createBrowserWithRouterAct('/')

        await act(async () => {
          await navigate(browser, '/has-page')
        })

        await retry(async () => {
          expect(await browser.elementByCss('#modal h3').text()).toContain(
            'TEST CASE 1'
          )
        })
      })

      /**
       * Test Case 2: No parallel routes (nested page)
       * Structure: @modal/(.)no-parallel-routes/deeper/page.tsx
       * Expected: Should work WITHOUT default.tsx at parent level
       * Reason: No parallel routes exist, so no implicit layout
       */
      it('should navigate to /no-parallel-routes/deeper without 404', async () => {
        const { act, browser } = await createBrowserWithRouterAct('/')

        await act(async () => {
          await navigate(browser, '/no-parallel-routes/deeper')
        })

        await retry(async () => {
          expect(await browser.elementByCss('#modal h3').text()).toContain(
            'TEST CASE 2'
          )
        })
      })

      /**
       * Test Case 3: Has both @sidebar AND page.tsx
       * Structure: @modal/(.)has-both/page.tsx + @sidebar/page.tsx
       * Expected: Should work WITHOUT default.tsx
       * Reason: page.tsx fills children slot, even though @sidebar creates implicit layout
       */
      it('should navigate to /has-both without 404 (has both @sidebar and page)', async () => {
        const { act, browser } = await createBrowserWithRouterAct('/')

        await act(async () => {
          await navigate(browser, '/has-both')
        })

        await retry(async () => {
          expect(await browser.elementByCss('#modal h3').text()).toContain(
            'TEST CASE 3'
          )
        })
      })

      /**
       * Test Case 4: Has @sidebar but NO page.tsx (THE KEY BUG CASE)
       * Structure: @modal/(.)test-nested/@sidebar/page.tsx (NO page.tsx at root)
       * Expected: Should work WITHOUT explicit default.tsx (auto null default)
       * Reason: Interception + parallel routes should inject null default
       *
       * This is the critical test! Without the fix:
       * 1. Server returns 404 (default.js calls notFound())
       * 2. Client sees !res.ok in fetch-server-response.ts:229
       * 3. Client triggers doMpaNavigation() - full page reload
       * 4. Navigation still succeeds via MPA, hiding the 404 bug
       *
       * With createRouterAct (no allowErrorStatusCodes), 404 fails the test.
       */
      it('should navigate to /test-nested without 404 (auto null default)', async () => {
        const { act, browser } = await createBrowserWithRouterAct('/')

        await act(async () => {
          await navigate(browser, '/test-nested')
        })

        await retry(async () => {
          // Modal should show intercepted content
          const modalContent = await browser.elementByCss('#modal').text()
          expect(modalContent).toContain('Intercepted test-nested sidebar')
        })

        await retry(async () => {
          // Children slot should still show original page (/)
          const childrenContent = await browser.elementByCss('#children').text()
          expect(childrenContent).toContain('CHILDREN SLOT')
        })
      })

      /**
       * Test Case 4b: Navigate deeper within intercepted route with parallel routes
       * This validates that navigating to the deeper page directly (from home) works
       */
      it('should navigate to /test-nested/deeper without 404', async () => {
        const { act, browser } = await createBrowserWithRouterAct('/')

        // Navigate directly to the deeper page from home
        await act(async () => {
          await navigate(browser, '/test-nested/deeper')
        })

        await retry(async () => {
          const modalContent = await browser.elementByCss('#modal').text()
          // Should show the deeper intercepted content
          expect(modalContent).toContain('deeper')
        })
      })

      it('should navigate to /regular-route/deeper without 404 (has page)', async () => {
        // Navigate directly via URL to avoid potential link click issues
        const browser = await next.browser('/regular-route/deeper')

        await retry(async () => {
          // Since this is NOT an interception route, we should see the actual page content
          // The page should render in the main content area, not in a modal
          const bodyText = await browser.elementByCss('body').text()
          expect(bodyText).toContain('Regular route without default.tsx')
          expect(bodyText).toContain('deeper/page.tsx')
        })
      })

      /**
       * Explicit layout test: Verify behavior with layout.tsx but no parallel routes
       */
      it('should navigate to /explicit-layout/deeper without 404', async () => {
        const { act, browser } = await createBrowserWithRouterAct('/')

        await act(async () => {
          await navigate(browser, '/explicit-layout/deeper')
        })

        await retry(async () => {
          const modalContent = await browser.elementByCss('#modal').text()
          expect(modalContent).toContain('Explicit layout')
          expect(modalContent).toContain('Deeper page under explicit layout')
        })
      })

      /**
       * Repeated navigation test: Validate __DEFAULT__ marker handling is consistent
       * Uses act() to ensure navigation requests return 200 (not 404). Each forward
       * navigation triggers an RSC request (even if cached), while back navigation
       * uses browser history without network requests.
       */
      it('should handle repeated interceptions without 404', async () => {
        const { act, browser } = await createBrowserWithRouterAct('/')

        for (let i = 0; i < 3; i++) {
          const isAccordionOpen = i > 0

          await expect(
            isAccordionClosed(browser, '/test-nested')
          ).resolves.toBe(!isAccordionOpen)

          // Forward navigation: triggers RSC request (validates no 404)
          await act(
            async () => {
              await navigate(browser, '/test-nested')
            },
            !isAccordionOpen ? undefined : 'no-requests'
          )

          await retry(async () => {
            const modalContent = await browser.elementByCss('#modal').text()
            expect(modalContent).toContain('Intercepted test-nested sidebar')
          })

          // Back navigation: uses browser history, no network request
          await act(async () => {
            await browser.back()
          }, 'no-requests')

          await retry(async () => {
            const url = await browser.url()
            expect(url).toMatch(/\/$/)
          })
        }
      })

      /**
       * Cross-interception navigation
       */
      it('should navigate between different interception routes without 404', async () => {
        const { act, browser } = await createBrowserWithRouterAct('/')

        // First interception
        await act(async () => {
          await navigate(browser, '/test-nested')
        })

        await retry(async () => {
          const modalContent = await browser.elementByCss('#modal').text()
          expect(modalContent).toContain('Intercepted test-nested sidebar')
        })

        // Second interception
        await act(async () => {
          await navigate(browser, '/has-both')
        })

        await retry(async () => {
          const modalContent = await browser.elementByCss('#modal').text()
          expect(modalContent).toContain('TEST CASE 3')
        })
      })
    })
  }
})
Quest for Codev2.0.0
/
SIGN IN