-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
test(e2e): Add Next.js cache component nesting scenarios #24704
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
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
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
There are no files selected for viewing
25 changes: 25 additions & 0 deletions
25
...ications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx
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,25 @@ | ||
| import type { ReactNode } from 'react'; | ||
| import { cacheLife } from 'next/cache'; | ||
|
|
||
| // The awaited param puts the request id into the cache key, so a fresh id is a guaranteed miss | ||
| // and the entry cannot come from a build-time fill. `children` passes through as an uncached hole. | ||
| export default async function CachedMidLayout({ | ||
| children, | ||
| params, | ||
| }: { | ||
| children: ReactNode; | ||
| params: Promise<{ id: string }>; | ||
| }) { | ||
| 'use cache'; | ||
| cacheLife('hours'); | ||
| const { id } = await params; | ||
| await new Promise(resolve => setTimeout(resolve, 100)); | ||
| return ( | ||
| <div data-testid="cached-mid-layout"> | ||
| <p id="cached-layout-stamp"> | ||
| {id}:{Date.now()} | ||
| </p> | ||
| {children} | ||
| </div> | ||
| ); | ||
| } |
6 changes: 6 additions & 0 deletions
6
...plications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx
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,6 @@ | ||
| import { headers } from 'next/headers'; | ||
|
|
||
| export default async function Page() { | ||
| await headers(); | ||
| return <p id="dynamic-leaf">Dynamic leaf: {Date.now()}</p>; | ||
| } |
17 changes: 17 additions & 0 deletions
17
...-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx
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,17 @@ | ||
| import { cacheLife } from 'next/cache'; | ||
|
|
||
| async function CachedLeaf({ id }: { id: string }) { | ||
| 'use cache'; | ||
| cacheLife('hours'); | ||
| await new Promise(resolve => setTimeout(resolve, 100)); | ||
| return ( | ||
| <p id="cached-leaf"> | ||
| {id}:{Date.now()} | ||
| </p> | ||
| ); | ||
| } | ||
|
|
||
| export default async function Page({ params }: { params: Promise<{ id: string }> }) { | ||
| const { id } = await params; | ||
| return <CachedLeaf id={id} />; | ||
| } |
7 changes: 7 additions & 0 deletions
7
...plications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx
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,7 @@ | ||
| import type { ReactNode } from 'react'; | ||
| import { headers } from 'next/headers'; | ||
|
|
||
| export default async function DynamicLayout({ children }: { children: ReactNode }) { | ||
| await headers(); | ||
| return <div data-testid="dynamic-layout">{children}</div>; | ||
| } |
11 changes: 11 additions & 0 deletions
11
...ges/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/layout.tsx
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,11 @@ | ||
| import { Suspense, type ReactNode } from 'react'; | ||
|
|
||
| // The Suspense boundary lets the nested layouts/pages below use request APIs (`headers()`, | ||
| // runtime params) without tripping the Cache Components prerender guards. | ||
| export default function Layout({ children }: { children: ReactNode }) { | ||
| return ( | ||
| <div data-testid="cached-nesting-group"> | ||
| <Suspense fallback={<div>Loading...</div>}>{children}</Suspense> | ||
| </div> | ||
| ); | ||
| } |
24 changes: 24 additions & 0 deletions
24
...plications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx
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,24 @@ | ||
| import type { ReactNode } from 'react'; | ||
| import { cacheLife } from 'next/cache'; | ||
|
|
||
| // Hard-expires after 2s while the page's cached component lives for hours, so one request can | ||
| // refill the layout while hitting the component: two cached levels with different origin traces. | ||
| export default async function ShortLivedLayout({ | ||
| children, | ||
| params, | ||
| }: { | ||
| children: ReactNode; | ||
| params: Promise<{ id: string }>; | ||
| }) { | ||
| 'use cache'; | ||
| cacheLife({ revalidate: 1, expire: 2 }); | ||
| const { id } = await params; | ||
| return ( | ||
| <div data-testid="short-lived-layout"> | ||
| <p id="layout-stamp"> | ||
| {id}:{Date.now()} | ||
| </p> | ||
| {children} | ||
| </div> | ||
| ); | ||
| } |
19 changes: 19 additions & 0 deletions
19
...applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx
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,19 @@ | ||
| import { headers } from 'next/headers'; | ||
| import { cacheLife } from 'next/cache'; | ||
|
|
||
| async function LongLivedComponent({ id }: { id: string }) { | ||
| 'use cache'; | ||
| cacheLife('hours'); | ||
| await new Promise(resolve => setTimeout(resolve, 100)); | ||
| return ( | ||
| <p id="component-stamp"> | ||
| {id}:{Date.now()} | ||
| </p> | ||
| ); | ||
| } | ||
|
|
||
| export default async function Page({ params }: { params: Promise<{ id: string }> }) { | ||
| const { id } = await params; | ||
| await headers(); | ||
| return <LongLivedComponent id={id} />; | ||
| } |
6 changes: 6 additions & 0 deletions
6
...applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx
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,6 @@ | ||
| import { headers } from 'next/headers'; | ||
|
|
||
| export default async function Page() { | ||
| await headers(); | ||
| return <p id="route-a">Route a: {Date.now()}</p>; | ||
| } |
6 changes: 6 additions & 0 deletions
6
...applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx
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,6 @@ | ||
| import { headers } from 'next/headers'; | ||
|
|
||
| export default async function Page() { | ||
| await headers(); | ||
| return <p id="route-b">Route b: {Date.now()}</p>; | ||
| } |
24 changes: 24 additions & 0 deletions
24
...applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx
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,24 @@ | ||
| import type { ReactNode } from 'react'; | ||
| import { cacheLife } from 'next/cache'; | ||
|
|
||
| // One cache entry (keyed by id) shared by the sibling routes `a` and `b` below. | ||
| export default async function SharedLayout({ | ||
| children, | ||
| params, | ||
| }: { | ||
| children: ReactNode; | ||
| params: Promise<{ id: string }>; | ||
| }) { | ||
| 'use cache'; | ||
| cacheLife('hours'); | ||
| const { id } = await params; | ||
| await new Promise(resolve => setTimeout(resolve, 100)); | ||
| return ( | ||
| <div data-testid="shared-layout"> | ||
| <p id="shared-layout-stamp"> | ||
| {id}:{Date.now()} | ||
| </p> | ||
| {children} | ||
| </div> | ||
| ); | ||
| } |
221 changes: 221 additions & 0 deletions
221
...-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts
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,221 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { waitForTransaction } from '@sentry-internal/test-utils'; | ||
|
|
||
| // Origin links (`sentry.link.type: 'cache_origin'` on `cache.get` hit spans, pointing at the | ||
| // filling `cache.put`) for `use cache` in nested layout trees under `app/(cached-nesting)/`. | ||
| // Not implemented yet — every test is `test.fail()` with the final expected assertions. | ||
|
|
||
| // A `use cache` layout between dynamic segments. The layout entry is keyed by the awaited [id] | ||
| // param. If Next serves the entry from the prerendered shell (Resume Data Cache) instead of the | ||
| // cache handlers, there is no `cache.get` span at all — then this stays failing until Next | ||
| // exposes RDC reads. | ||
| test('links a cached layout hit to the trace that filled the layout entry', async ({ request }) => { | ||
| test.fail(); | ||
|
|
||
| const id = crypto.randomUUID(); | ||
|
|
||
| const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { | ||
| return ( | ||
| transactionEvent.transaction === 'GET /cached-mid-layout/[id]' && | ||
| !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === false) | ||
| ); | ||
| }); | ||
|
|
||
| const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { | ||
| return ( | ||
| transactionEvent.transaction === 'GET /cached-mid-layout/[id]' && | ||
| !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) | ||
| ); | ||
| }); | ||
|
|
||
| await request.get(`/cached-mid-layout/${id}`); | ||
| const missTx = await missTxPromise; | ||
|
|
||
| await request.get(`/cached-mid-layout/${id}`); | ||
| const hitTx = await hitTxPromise; | ||
|
|
||
| // The layout is the only cached entry on this route. | ||
| const putSpans = (missTx.spans ?? []).filter(span => span.op === 'cache.put'); | ||
| expect(new Set(putSpans.map(span => span.description)).size).toBe(1); | ||
|
|
||
| const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true); | ||
| expect(hitGetSpan).toBeDefined(); | ||
| expect(hitGetSpan?.description).toBe(putSpans[0]!.description); | ||
| expect(hitGetSpan?.links).toEqual([ | ||
| { | ||
| trace_id: missTx.contexts?.trace?.trace_id, | ||
| span_id: putSpans[0]!.span_id, | ||
| sampled: true, | ||
| attributes: { 'sentry.link.type': 'cache_origin' }, | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| // Inverse nesting: all layouts above are dynamic, only the leaf component is cached — the leaf | ||
| // entry is the only span that carries a link. | ||
| test('links a cached leaf component under dynamic layouts to the trace that filled the leaf entry', async ({ | ||
| request, | ||
| }) => { | ||
| test.fail(); | ||
|
|
||
| const id = crypto.randomUUID(); | ||
|
|
||
| const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { | ||
| return ( | ||
| transactionEvent.transaction === 'GET /dynamic-layouts/[id]/layout-cached-leaf' && | ||
| !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === false) | ||
| ); | ||
| }); | ||
|
|
||
| const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { | ||
| return ( | ||
| transactionEvent.transaction === 'GET /dynamic-layouts/[id]/layout-cached-leaf' && | ||
| !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) | ||
| ); | ||
| }); | ||
|
|
||
| await request.get(`/dynamic-layouts/${id}/layout-cached-leaf`); | ||
| const missTx = await missTxPromise; | ||
|
|
||
| await request.get(`/dynamic-layouts/${id}/layout-cached-leaf`); | ||
| const hitTx = await hitTxPromise; | ||
|
|
||
| const putSpans = (missTx.spans ?? []).filter(span => span.op === 'cache.put'); | ||
| expect(new Set(putSpans.map(span => span.description)).size).toBe(1); | ||
|
|
||
| const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true); | ||
| expect(hitGetSpan).toBeDefined(); | ||
| expect(hitGetSpan?.description).toBe(putSpans[0]!.description); | ||
| expect(hitGetSpan?.links).toEqual([ | ||
| { | ||
| trace_id: missTx.contexts?.trace?.trace_id, | ||
| span_id: putSpans[0]!.span_id, | ||
| sampled: true, | ||
| attributes: { 'sentry.link.type': 'cache_origin' }, | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| // Nested levels with different lifetimes: after the layout expired, request 2 refills the layout | ||
| // while the component still hits. Request 3 then hits both entries, and its two `cache.get` | ||
| // spans point at two different origin traces. | ||
| test('links two cached levels to different origin traces after the layout expires', async ({ request }) => { | ||
| test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); | ||
| test.fail(); | ||
|
|
||
| const id = crypto.randomUUID(); | ||
|
|
||
| const fillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { | ||
| return ( | ||
| transactionEvent.transaction === 'GET /mixed-lifetimes/[id]' && | ||
| !!transactionEvent.spans?.some(span => span.op === 'cache.put') | ||
| ); | ||
| }); | ||
|
|
||
| await request.get(`/mixed-lifetimes/${id}`); | ||
| const fillTx = await fillTxPromise; | ||
|
|
||
| // Layout + component entry. | ||
| const fillPutSpans = (fillTx.spans ?? []).filter(span => span.op === 'cache.put'); | ||
| expect(new Set(fillPutSpans.map(span => span.description)).size).toBe(2); | ||
|
|
||
| // Sleep past the layout's `expire` (2s); the component entry stays valid for hours. | ||
| await new Promise(resolve => setTimeout(resolve, 3_000)); | ||
|
|
||
| const refillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { | ||
| return ( | ||
| transactionEvent.transaction === 'GET /mixed-lifetimes/[id]' && | ||
| !!transactionEvent.spans?.some(span => span.op === 'cache.put') && | ||
| !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) | ||
| ); | ||
| }); | ||
|
|
||
| await request.get(`/mixed-lifetimes/${id}`); | ||
| const refillTx = await refillTxPromise; | ||
|
|
||
| const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { | ||
| return ( | ||
| transactionEvent.transaction === 'GET /mixed-lifetimes/[id]' && | ||
| (transactionEvent.spans?.filter(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true).length ?? | ||
| 0) >= 2 | ||
| ); | ||
| }); | ||
|
|
||
| await request.get(`/mixed-lifetimes/${id}`); | ||
| const hitTx = await hitTxPromise; | ||
|
|
||
| const layoutPutSpan = refillTx.spans?.find(span => span.op === 'cache.put'); | ||
| expect(layoutPutSpan).toBeDefined(); | ||
| const componentPutSpan = fillPutSpans.find(span => span.description !== layoutPutSpan!.description); | ||
| expect(componentPutSpan).toBeDefined(); | ||
| expect(refillTx.contexts?.trace?.trace_id).not.toBe(fillTx.contexts?.trace?.trace_id); | ||
|
|
||
| const layoutHitSpan = hitTx.spans?.find( | ||
| span => span.op === 'cache.get' && span.description === layoutPutSpan!.description, | ||
| ); | ||
| expect(layoutHitSpan?.links).toEqual([ | ||
| { | ||
| trace_id: refillTx.contexts?.trace?.trace_id, | ||
| span_id: layoutPutSpan!.span_id, | ||
| sampled: true, | ||
| attributes: { 'sentry.link.type': 'cache_origin' }, | ||
| }, | ||
| ]); | ||
|
|
||
| const componentHitSpan = hitTx.spans?.find( | ||
| span => span.op === 'cache.get' && span.description === componentPutSpan!.description, | ||
| ); | ||
| expect(componentHitSpan?.links).toEqual([ | ||
| { | ||
| trace_id: fillTx.contexts?.trace?.trace_id, | ||
| span_id: componentPutSpan!.span_id, | ||
| sampled: true, | ||
| attributes: { 'sentry.link.type': 'cache_origin' }, | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| // Routes `a` and `b` share one layout entry: a hit on `b` links to the fill trace of `a`, so the | ||
| // origin is a different transaction than the serving one. | ||
| test('links a shared layout hit on a sibling route to the trace of the route that filled the entry', async ({ | ||
| request, | ||
| }) => { | ||
| test.fail(); | ||
|
|
||
| const id = crypto.randomUUID(); | ||
|
|
||
| const fillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { | ||
| return ( | ||
| transactionEvent.transaction === 'GET /shared-layout/[id]/a' && | ||
| !!transactionEvent.spans?.some(span => span.op === 'cache.put') | ||
| ); | ||
| }); | ||
|
|
||
| await request.get(`/shared-layout/${id}/a`); | ||
| const fillTx = await fillTxPromise; | ||
|
|
||
| const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { | ||
| return ( | ||
| transactionEvent.transaction === 'GET /shared-layout/[id]/b' && | ||
| !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) | ||
| ); | ||
| }); | ||
|
|
||
| await request.get(`/shared-layout/${id}/b`); | ||
| const hitTx = await hitTxPromise; | ||
|
|
||
| const putSpan = fillTx.spans?.find(span => span.op === 'cache.put'); | ||
| expect(putSpan).toBeDefined(); | ||
|
|
||
| const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true); | ||
| expect(hitGetSpan).toBeDefined(); | ||
| expect(hitGetSpan?.description).toBe(putSpan!.description); | ||
| expect(hitGetSpan?.links).toEqual([ | ||
| { | ||
| trace_id: fillTx.contexts?.trace?.trace_id, | ||
| span_id: putSpan!.span_id, | ||
| sampled: true, | ||
| attributes: { 'sentry.link.type': 'cache_origin' }, | ||
| }, | ||
| ]); | ||
| }); | ||
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.