Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 (
<section id={`cached-${label}`}>
{label}:{id}:{Date.now()}
</section>
);
}

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 (
<>
<CachedSection id={id} label="first" />
<CachedSection id={id} label="second" />
</>
);
}

export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) {
return (
<Suspense fallback={<div>Loading...</div>}>
<DynamicContent searchParams={searchParams} />
</Suspense>
);
}
Original file line number Diff line number Diff line change
@@ -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 <div id="nested-data">{JSON.stringify({ ...nested, renderedAt: Date.now() })}</div>;
}

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 <ShortLivedSection id={id} />;
}

export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) {
return (
<Suspense fallback={<div>Loading...</div>}>
<DynamicContent searchParams={searchParams} />
</Suspense>
);
}
Original file line number Diff line number Diff line change
@@ -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' },
},
]);
});
Original file line number Diff line number Diff line change
@@ -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 (
<section id={`cached-${label}`}>
{label}:{id}:{Date.now()}
</section>
);
}

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 (
<>
<CachedSection id={id} label="first" />
<CachedSection id={id} label="second" />
</>
);
}

export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) {
return (
<Suspense fallback={<div>Loading...</div>}>
<DynamicContent searchParams={searchParams} />
</Suspense>
);
}
Original file line number Diff line number Diff line change
@@ -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 <div id="nested-data">{JSON.stringify({ ...nested, renderedAt: Date.now() })}</div>;
}

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 <ShortLivedSection id={id} />;
}

export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) {
return (
<Suspense fallback={<div>Loading...</div>}>
<DynamicContent searchParams={searchParams} />
</Suspense>
);
}
Original file line number Diff line number Diff line change
@@ -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,
},
]);
});
Loading