diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/cached-sibling-components/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/cached-sibling-components/page.tsx new file mode 100644 index 000000000000..c8b8858f7785 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/cached-sibling-components/page.tsx @@ -0,0 +1,34 @@ +import { Suspense } from 'react'; +import { cacheLife } from 'next/cache'; + +async function CachedSection({ id, label }: { id: string; label: string }) { + 'use cache'; + cacheLife('hours'); + await new Promise(resolve => setTimeout(resolve, 100)); + return ( +
+ {label}:{id}:{Date.now()} +
+ ); +} + +async function DynamicContent({ searchParams }: { searchParams: Promise<{ id?: string }> }) { + // Awaiting searchParams makes this hole dynamic, so every request renders it and consults the + // cache handler instead of serving prerendered output. The two sibling components cache as two + // separate entries (the props are part of the cache key). + const { id = 'default-id' } = await searchParams; + return ( + <> + + + + ); +} + +export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) { + return ( + Loading...}> + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/nested-caches/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/nested-caches/page.tsx new file mode 100644 index 000000000000..481d7e5e46e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/nested-caches/page.tsx @@ -0,0 +1,33 @@ +import { Suspense } from 'react'; +import { cacheLife } from 'next/cache'; + +async function getSlowChangingValue(id: string): Promise<{ id: string; createdAt: number }> { + 'use cache'; + cacheLife('hours'); + await new Promise(resolve => setTimeout(resolve, 100)); + return { id, createdAt: Date.now() }; +} + +async function ShortLivedSection({ id }: { id: string }) { + 'use cache'; + // The component entry hard-expires after 2s while the nested function entry lives on, so a + // delayed request refills the component and reads the nested entry as a hit inside that fill. + cacheLife({ revalidate: 1, expire: 2 }); + const nested = await getSlowChangingValue(id); + return
{JSON.stringify({ ...nested, renderedAt: Date.now() })}
; +} + +async function DynamicContent({ searchParams }: { searchParams: Promise<{ id?: string }> }) { + // Awaiting searchParams makes this hole dynamic, so every request renders it and consults the + // cache handler instead of serving prerendered output. + const { id = 'default-id' } = await searchParams; + return ; +} + +export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) { + return ( + Loading...}> + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-page.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-page.spec.ts new file mode 100644 index 000000000000..a0acf25df078 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-page.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// Origin links for `use cache` inside rendered pages (cached components and nested cached +// functions). Target behavior: a cache hit records a `cache.get` span carrying a +// `sentry.link.type: 'cache_origin'` span link to the `cache.put` span of the trace that filled +// the entry. Not implemented yet — every test is `test.fail()`. + +// Two cached sibling components are two cache entries (props are part of the key), so one request +// carries one `cache.get` hit span per section, each linking to its own fill. The components sit +// in a dynamic hole: entries served from the prerendered shell (Resume Data Cache) never reach +// the cache handlers and produce no spans until Next.js exposes RDC reads. +test('links each sibling component hit to the fill of its own entry', async ({ request }) => { + test.fail(); + + const id = crypto.randomUUID(); + + const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /cached-sibling-components' && + !!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-sibling-components' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) + ); + }); + + await request.get(`/cached-sibling-components?id=${id}`); + const missTx = await missTxPromise; + + await request.get(`/cached-sibling-components?id=${id}`); + const hitTx = await hitTxPromise; + + // Without span streaming, the key digest doubles as the span description, so it pairs a hit + // with the put that filled the same entry. + const putSpansByDigest = new Map( + (missTx.spans ?? []).filter(span => span.op === 'cache.put').map(span => [span.description, span] as const), + ); + expect(putSpansByDigest.size).toBe(2); + + const hitGetSpans = (hitTx.spans ?? []).filter(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true); + // Deduplicated by entry because dev mode can render (and therefore read) more than once. + expect(new Set(hitGetSpans.map(span => span.description)).size).toBe(2); + + for (const hitSpan of hitGetSpans) { + const putSpan = putSpansByDigest.get(hitSpan.description); + expect(putSpan).toBeDefined(); + expect(hitSpan.links).toEqual([ + { + trace_id: missTx.contexts?.trace?.trace_id, + span_id: putSpan!.span_id, + sampled: true, + attributes: { 'sentry.link.type': 'cache_origin' }, + }, + ]); + } + + // The two sections link to two different fill spans, not to one shared origin. + expect(new Set(hitGetSpans.map(span => span.links?.[0]?.span_id)).size).toBe(2); +}); + +test("links a nested cache hit inside another entry's refill to the original fill trace", async ({ request }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); + test.fail(); + + const id = crypto.randomUUID(); + + const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /nested-caches' && + !!transactionEvent.spans?.some(span => span.op === 'cache.put') + ); + }); + + await request.get(`/nested-caches?id=${id}`); + const missTx = await missTxPromise; + + // Sleep past the component entry's hard `expire` limit (2s); the nested function entry + // (`cacheLife('hours')`) stays valid. + await new Promise(resolve => setTimeout(resolve, 3_000)); + + const refillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /nested-caches' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) + ); + }); + + await request.get(`/nested-caches?id=${id}`); + const refillTx = await refillTxPromise; + + // The only hit is the nested function entry, read while the expired component entry refills. + const hitGetSpans = (refillTx.spans ?? []).filter( + span => span.op === 'cache.get' && span.data?.['cache.hit'] === true, + ); + expect(hitGetSpans).toHaveLength(1); + + const nestedPutSpan = missTx.spans?.find( + span => span.op === 'cache.put' && span.description === hitGetSpans[0]!.description, + ); + expect(nestedPutSpan).toBeDefined(); + + // Even though the read happens inside the component's isolated fill context, the link still + // points at the trace that originally filled the nested entry. + expect(hitGetSpans[0]!.links).toEqual([ + { + trace_id: missTx.contexts?.trace?.trace_id, + span_id: nestedPutSpan!.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-sibling-components/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/cached-sibling-components/page.tsx new file mode 100644 index 000000000000..c8b8858f7785 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/cached-sibling-components/page.tsx @@ -0,0 +1,34 @@ +import { Suspense } from 'react'; +import { cacheLife } from 'next/cache'; + +async function CachedSection({ id, label }: { id: string; label: string }) { + 'use cache'; + cacheLife('hours'); + await new Promise(resolve => setTimeout(resolve, 100)); + return ( +
+ {label}:{id}:{Date.now()} +
+ ); +} + +async function DynamicContent({ searchParams }: { searchParams: Promise<{ id?: string }> }) { + // Awaiting searchParams makes this hole dynamic, so every request renders it and consults the + // cache handler instead of serving prerendered output. The two sibling components cache as two + // separate entries (the props are part of the cache key). + const { id = 'default-id' } = await searchParams; + return ( + <> + + + + ); +} + +export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) { + return ( + Loading...}> + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/nested-caches/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/nested-caches/page.tsx new file mode 100644 index 000000000000..481d7e5e46e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/nested-caches/page.tsx @@ -0,0 +1,33 @@ +import { Suspense } from 'react'; +import { cacheLife } from 'next/cache'; + +async function getSlowChangingValue(id: string): Promise<{ id: string; createdAt: number }> { + 'use cache'; + cacheLife('hours'); + await new Promise(resolve => setTimeout(resolve, 100)); + return { id, createdAt: Date.now() }; +} + +async function ShortLivedSection({ id }: { id: string }) { + 'use cache'; + // The component entry hard-expires after 2s while the nested function entry lives on, so a + // delayed request refills the component and reads the nested entry as a hit inside that fill. + cacheLife({ revalidate: 1, expire: 2 }); + const nested = await getSlowChangingValue(id); + return
{JSON.stringify({ ...nested, renderedAt: Date.now() })}
; +} + +async function DynamicContent({ searchParams }: { searchParams: Promise<{ id?: string }> }) { + // Awaiting searchParams makes this hole dynamic, so every request renders it and consults the + // cache handler instead of serving prerendered output. + const { id = 'default-id' } = await searchParams; + return ; +} + +export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) { + return ( + Loading...}> + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-page.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-page.spec.ts new file mode 100644 index 000000000000..fd8a86a5c848 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-page.spec.ts @@ -0,0 +1,125 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { CACHE_ORIGIN_LINK_ATTRIBUTES } from './cacheOriginLinks-utils'; + +// Origin links for `use cache` inside rendered pages (cached components and nested cached +// functions). Target behavior: a cache hit records a `cache.get` span carrying a +// `sentry.link.type: 'cache_origin'` span link to the `cache.put` span of the trace that filled +// the entry. Not implemented yet — every test is `test.fail()`. + +// Two cached sibling components are two cache entries (props are part of the key), so one request +// carries one `cache.get` hit span per section, each linking to its own fill. The components sit +// in a dynamic hole: entries served from the prerendered shell (Resume Data Cache) never reach +// the cache handlers and produce no spans until Next.js exposes RDC reads. +test('links each sibling component hit to the fill of its own 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-sibling-components' && span.is_segment) && + spansOfTrace.filter(span => getSpanOp(span) === 'cache.put').length >= 2 + ); + }); + + await request.get(`/cached-sibling-components?id=${id}`); + const missSpans = await missSpansPromise; + + const hitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /cached-sibling-components' && span.is_segment) && + spansOfTrace.filter(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) + .length >= 2 + ); + }); + + await request.get(`/cached-sibling-components?id=${id}`); + const hitSpans = await hitSpansPromise; + + // The key digest (`cache.key`) pairs a hit with the put that filled the same entry. + const putSpansByKey = new Map( + missSpans + .filter(span => getSpanOp(span) === 'cache.put') + .map(span => [JSON.stringify(span.attributes['cache.key']?.value), span] as const), + ); + expect(putSpansByKey.size).toBe(2); + + const hitGetSpans = hitSpans.filter( + span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true, + ); + // Deduplicated by entry because dev mode can render (and therefore read) more than once. + expect(new Set(hitGetSpans.map(span => JSON.stringify(span.attributes['cache.key']?.value))).size).toBe(2); + + for (const hitSpan of hitGetSpans) { + const putSpan = putSpansByKey.get(JSON.stringify(hitSpan.attributes['cache.key']?.value)); + expect(putSpan).toBeDefined(); + expect(hitSpan.links).toEqual([ + { + trace_id: putSpan!.trace_id, + span_id: putSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); + } + + // The two sections link to two different fill spans, not to one shared origin. + expect(new Set(hitGetSpans.map(span => span.links?.[0]?.span_id)).size).toBe(2); +}); + +test("links a nested cache hit inside another entry's refill to the original fill trace", async ({ request }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); + test.fail(); + + const id = crypto.randomUUID(); + + const missSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /nested-caches' && span.is_segment) && + spansOfTrace.filter(span => getSpanOp(span) === 'cache.put').length >= 2 + ); + }); + + await request.get(`/nested-caches?id=${id}`); + const missSpans = await missSpansPromise; + + // Sleep past the component entry's hard `expire` limit (2s); the nested function entry + // (`cacheLife('hours')`) stays valid. + await new Promise(resolve => setTimeout(resolve, 3_000)); + + const refillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /nested-caches' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) + ); + }); + + await request.get(`/nested-caches?id=${id}`); + const refillSpans = await refillSpansPromise; + + // The only hit is the nested function entry, read while the expired component entry refills. + const hitGetSpans = refillSpans.filter( + span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true, + ); + expect(hitGetSpans).toHaveLength(1); + + const nestedPutSpan = missSpans.find( + span => + getSpanOp(span) === 'cache.put' && + JSON.stringify(span.attributes['cache.key']?.value) === + JSON.stringify(hitGetSpans[0]!.attributes['cache.key']?.value), + ); + expect(nestedPutSpan).toBeDefined(); + + // Even though the read happens inside the component's isolated fill context, the link still + // points at the trace that originally filled the nested entry. + expect(hitGetSpans[0]!.links).toEqual([ + { + trace_id: nestedPutSpan!.trace_id, + span_id: nestedPutSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); +});