From a1e8ad15f7e5fc953cf45ccce53995268c010ec3 Mon Sep 17 00:00:00 2001
From: s1gr1d <32902192+s1gr1d@users.noreply.github.com>
Date: Fri, 25 Sep 2026 14:03:09 +0200
Subject: [PATCH] test(e2e): Add cache origin link tests for cached components
in pages
---
.../app/cached-sibling-components/page.tsx | 34 +++++
.../app/nested-caches/page.tsx | 33 +++++
.../tests/cacheOriginLinks-page.spec.ts | 117 ++++++++++++++++
.../app/cached-sibling-components/page.tsx | 34 +++++
.../app/nested-caches/page.tsx | 33 +++++
.../tests/cacheOriginLinks-page.spec.ts | 125 ++++++++++++++++++
6 files changed, 376 insertions(+)
create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/cached-sibling-components/page.tsx
create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/nested-caches/page.tsx
create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-page.spec.ts
create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/cached-sibling-components/page.tsx
create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/nested-caches/page.tsx
create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-page.spec.ts
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,
+ },
+ ]);
+});