diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx new file mode 100644 index 000000000000..a61f15ee7e3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx @@ -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 ( +
+

+ {id}:{Date.now()} +

+ {children} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx new file mode 100644 index 000000000000..94196e6e5ee8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx @@ -0,0 +1,6 @@ +import { headers } from 'next/headers'; + +export default async function Page() { + await headers(); + return

Dynamic leaf: {Date.now()}

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx new file mode 100644 index 000000000000..8e7de36d156a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx @@ -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 ( +

+ {id}:{Date.now()} +

+ ); +} + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx new file mode 100644 index 000000000000..412587fd8f33 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx @@ -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
{children}
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/layout.tsx new file mode 100644 index 000000000000..84a6395b39ac --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/layout.tsx @@ -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 ( +
+ Loading...
}>{children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx new file mode 100644 index 000000000000..2b0b77b6e3a9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx @@ -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 ( +
+

+ {id}:{Date.now()} +

+ {children} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx new file mode 100644 index 000000000000..18913cbe9790 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx @@ -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 ( +

+ {id}:{Date.now()} +

+ ); +} + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + await headers(); + return ; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx new file mode 100644 index 000000000000..ec8d5e21d947 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx @@ -0,0 +1,6 @@ +import { headers } from 'next/headers'; + +export default async function Page() { + await headers(); + return

Route a: {Date.now()}

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx new file mode 100644 index 000000000000..9a1b84b2ca28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx @@ -0,0 +1,6 @@ +import { headers } from 'next/headers'; + +export default async function Page() { + await headers(); + return

Route b: {Date.now()}

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx new file mode 100644 index 000000000000..220fdcf92ca8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx @@ -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 ( +
+

+ {id}:{Date.now()} +

+ {children} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts new file mode 100644 index 000000000000..5025d7d2e104 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts @@ -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' }, + }, + ]); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx new file mode 100644 index 000000000000..a61f15ee7e3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx @@ -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 ( +
+

+ {id}:{Date.now()} +

+ {children} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx new file mode 100644 index 000000000000..94196e6e5ee8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx @@ -0,0 +1,6 @@ +import { headers } from 'next/headers'; + +export default async function Page() { + await headers(); + return

Dynamic leaf: {Date.now()}

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx new file mode 100644 index 000000000000..8e7de36d156a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx @@ -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 ( +

+ {id}:{Date.now()} +

+ ); +} + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx new file mode 100644 index 000000000000..412587fd8f33 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx @@ -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
{children}
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/layout.tsx new file mode 100644 index 000000000000..84a6395b39ac --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/layout.tsx @@ -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 ( +
+ Loading...
}>{children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx new file mode 100644 index 000000000000..2b0b77b6e3a9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx @@ -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 ( +
+

+ {id}:{Date.now()} +

+ {children} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx new file mode 100644 index 000000000000..18913cbe9790 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx @@ -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 ( +

+ {id}:{Date.now()} +

+ ); +} + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + await headers(); + return ; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx new file mode 100644 index 000000000000..ec8d5e21d947 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx @@ -0,0 +1,6 @@ +import { headers } from 'next/headers'; + +export default async function Page() { + await headers(); + return

Route a: {Date.now()}

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx new file mode 100644 index 000000000000..9a1b84b2ca28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx @@ -0,0 +1,6 @@ +import { headers } from 'next/headers'; + +export default async function Page() { + await headers(); + return

Route b: {Date.now()}

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx new file mode 100644 index 000000000000..220fdcf92ca8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx @@ -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 ( +
+

+ {id}:{Date.now()} +

+ {children} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts new file mode 100644 index 000000000000..d456686f24ec --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts @@ -0,0 +1,226 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { CACHE_ORIGIN_LINK_ATTRIBUTES, findCacheSpan } from './cacheOriginLinks-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 missSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /cached-mid-layout/[id]' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.put') + ); + }); + + await request.get(`/cached-mid-layout/${id}`); + const missSpans = await missSpansPromise; + + const hitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /cached-mid-layout/[id]' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) + ); + }); + + await request.get(`/cached-mid-layout/${id}`); + const hitSpans = await hitSpansPromise; + + // The layout is the only cached entry on this route. + const putSpan = findCacheSpan(missSpans, 'cache.put'); + expect(putSpan).toBeDefined(); + + const hitGetSpan = findCacheSpan(hitSpans, 'cache.get', true); + expect(hitGetSpan).toBeDefined(); + expect(hitGetSpan!.attributes['cache.key']).toEqual(putSpan!.attributes['cache.key']); + expect(hitGetSpan?.links).toEqual([ + { + trace_id: putSpan!.trace_id, + span_id: putSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); +}); + +// 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 missSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /dynamic-layouts/[id]/layout-cached-leaf' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.put') + ); + }); + + await request.get(`/dynamic-layouts/${id}/layout-cached-leaf`); + const missSpans = await missSpansPromise; + + const hitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /dynamic-layouts/[id]/layout-cached-leaf' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) + ); + }); + + await request.get(`/dynamic-layouts/${id}/layout-cached-leaf`); + const hitSpans = await hitSpansPromise; + + const putSpan = findCacheSpan(missSpans, 'cache.put'); + expect(putSpan).toBeDefined(); + + const hitGetSpan = findCacheSpan(hitSpans, 'cache.get', true); + expect(hitGetSpan).toBeDefined(); + expect(hitGetSpan!.attributes['cache.key']).toEqual(putSpan!.attributes['cache.key']); + expect(hitGetSpan?.links).toEqual([ + { + trace_id: putSpan!.trace_id, + span_id: putSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); +}); + +// 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 fillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /mixed-lifetimes/[id]' && span.is_segment) && + spansOfTrace.filter(span => getSpanOp(span) === 'cache.put').length >= 2 + ); + }); + + await request.get(`/mixed-lifetimes/${id}`); + const fillSpans = await fillSpansPromise; + + // Layout + component entry. + const fillPutSpans = fillSpans.filter(span => getSpanOp(span) === 'cache.put'); + expect(new Set(fillPutSpans.map(span => JSON.stringify(span.attributes['cache.key']?.value))).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 refillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /mixed-lifetimes/[id]' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.put') && + spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) + ); + }); + + await request.get(`/mixed-lifetimes/${id}`); + const refillSpans = await refillSpansPromise; + + const hitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /mixed-lifetimes/[id]' && span.is_segment) && + spansOfTrace.filter(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) + .length >= 2 + ); + }); + + await request.get(`/mixed-lifetimes/${id}`); + const hitSpans = await hitSpansPromise; + + const layoutPutSpan = findCacheSpan(refillSpans, 'cache.put'); + expect(layoutPutSpan).toBeDefined(); + const layoutKey = JSON.stringify(layoutPutSpan!.attributes['cache.key']?.value); + const componentPutSpan = fillPutSpans.find(span => JSON.stringify(span.attributes['cache.key']?.value) !== layoutKey); + expect(componentPutSpan).toBeDefined(); + expect(layoutPutSpan!.trace_id).not.toBe(componentPutSpan!.trace_id); + + const layoutHitSpan = hitSpans.find( + span => getSpanOp(span) === 'cache.get' && JSON.stringify(span.attributes['cache.key']?.value) === layoutKey, + ); + expect(layoutHitSpan?.links).toEqual([ + { + trace_id: layoutPutSpan!.trace_id, + span_id: layoutPutSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); + + const componentHitSpan = hitSpans.find( + span => + getSpanOp(span) === 'cache.get' && + JSON.stringify(span.attributes['cache.key']?.value) === + JSON.stringify(componentPutSpan!.attributes['cache.key']?.value), + ); + expect(componentHitSpan?.links).toEqual([ + { + trace_id: componentPutSpan!.trace_id, + span_id: componentPutSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); +}); + +// 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 fillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /shared-layout/[id]/a' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.put') + ); + }); + + await request.get(`/shared-layout/${id}/a`); + const fillSpans = await fillSpansPromise; + + const hitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /shared-layout/[id]/b' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) + ); + }); + + await request.get(`/shared-layout/${id}/b`); + const hitSpans = await hitSpansPromise; + + const putSpan = findCacheSpan(fillSpans, 'cache.put'); + expect(putSpan).toBeDefined(); + + const hitGetSpan = findCacheSpan(hitSpans, 'cache.get', true); + expect(hitGetSpan).toBeDefined(); + expect(hitGetSpan!.attributes['cache.key']).toEqual(putSpan!.attributes['cache.key']); + expect(hitGetSpan?.links).toEqual([ + { + trace_id: putSpan!.trace_id, + span_id: putSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-utils.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-utils.ts new file mode 100644 index 000000000000..d43ce40324fb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-utils.ts @@ -0,0 +1,16 @@ +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { getSpanOp } from '@sentry-internal/test-utils'; + +export const CACHE_ORIGIN_LINK_ATTRIBUTES = { + 'sentry.link.type': { value: 'cache_origin', type: 'string' }, +}; + +export function findCacheSpan( + spans: SerializedStreamedSpan[], + op: 'cache.get' | 'cache.put', + hit?: boolean, +): SerializedStreamedSpan | undefined { + return spans.find( + span => getSpanOp(span) === op && (hit === undefined || span.attributes['cache.hit']?.value === hit), + ); +}