From 6c2fa19ec6ae723a9939c1c168f452a2e52b6028 Mon Sep 17 00:00:00 2001
From: s1gr1d <32902192+s1gr1d@users.noreply.github.com>
Date: Thu, 10 Sep 2026 14:24:12 +0200
Subject: [PATCH 1/2] test(nextjs): Add E2E assertions for `use cache`
functions
---
.../app/api/use-cache-expiring/route.ts | 14 ++
.../app/api/use-cache/route.ts | 18 +++
.../app/use-cache-page/page.tsx | 25 +++
.../tests/useCacheSpans.spec.ts | 149 ++++++++++++++++++
4 files changed, 206 insertions(+)
create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/use-cache-expiring/route.ts
create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/use-cache/route.ts
create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/use-cache-page/page.tsx
create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/useCacheSpans.spec.ts
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/use-cache-expiring/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/use-cache-expiring/route.ts
new file mode 100644
index 000000000000..ca237ca4785f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/use-cache-expiring/route.ts
@@ -0,0 +1,14 @@
+import { cacheLife } from 'next/cache';
+import type { NextRequest } from 'next/server';
+
+async function getExpiringValue(id: string): Promise<{ id: string; createdAt: number }> {
+ 'use cache';
+ // Hard-expires after 2 seconds, so a delayed second request exercises the expired-entry path.
+ cacheLife({ revalidate: 1, expire: 2 });
+ return { id, createdAt: Date.now() };
+}
+
+export async function GET(request: NextRequest) {
+ const id = request.nextUrl.searchParams.get('id') ?? 'default-id';
+ return Response.json(await getExpiringValue(id));
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/use-cache/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/use-cache/route.ts
new file mode 100644
index 000000000000..dd5f63024523
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/use-cache/route.ts
@@ -0,0 +1,18 @@
+import { cacheLife, cacheTag } from 'next/cache';
+import type { NextRequest } from 'next/server';
+
+async function getCachedValue(id: string): Promise<{ id: string; createdAt: number }> {
+ 'use cache';
+ // The 'hours' profile has a finite `expire` (1 day), so the entry carries a real TTL.
+ cacheLife('hours');
+ cacheTag('e2e-use-cache-tag');
+ await new Promise(resolve => setTimeout(resolve, 100));
+ return { id, createdAt: Date.now() };
+}
+
+export async function GET(request: NextRequest) {
+ // The id ends up in the cache key (it is an argument of the cached function), so tests get a
+ // guaranteed cache miss by passing a fresh id.
+ const id = request.nextUrl.searchParams.get('id') ?? 'default-id';
+ return Response.json(await getCachedValue(id));
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/use-cache-page/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/use-cache-page/page.tsx
new file mode 100644
index 000000000000..1e41b4436e20
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/use-cache-page/page.tsx
@@ -0,0 +1,25 @@
+import { Suspense } from 'react';
+import { cacheLife } from 'next/cache';
+
+async function getCachedPageData(id: string): Promise<{ id: string; createdAt: number }> {
+ 'use cache';
+ cacheLife('hours');
+ await new Promise(resolve => setTimeout(resolve, 100));
+ return { id, createdAt: Date.now() };
+}
+
+export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) {
+ return (
+ Loading...}>
+
+
+ );
+}
+
+async function CachedContent({ 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;
+ const data = await getCachedPageData(id);
+ return
{JSON.stringify(data)}
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/useCacheSpans.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/useCacheSpans.spec.ts
new file mode 100644
index 000000000000..47502f18e989
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/useCacheSpans.spec.ts
@@ -0,0 +1,149 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+test('Should create cache spans around `use cache` functions', async ({ request }) => {
+ // A fresh id makes the first request a guaranteed cache miss (the id is part of the cache key)
+ // even when the test is retried against the same server.
+ const id = crypto.randomUUID();
+
+ const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
+ return (
+ transactionEvent.transaction === 'GET /api/use-cache' &&
+ !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === false)
+ );
+ });
+
+ const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
+ return (
+ transactionEvent.transaction === 'GET /api/use-cache' &&
+ !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true)
+ );
+ });
+
+ const firstResponse = await (await request.get(`/api/use-cache?id=${id}`)).json();
+ const missTx = await missTxPromise;
+
+ const secondResponse = await (await request.get(`/api/use-cache?id=${id}`)).json();
+ const hitTx = await hitTxPromise;
+
+ // The second request must have been served from the cache.
+ expect(firstResponse.id).toBe(id);
+ expect(secondResponse).toEqual(firstResponse);
+
+ const missGetSpan = missTx.spans?.find(span => span.op === 'cache.get');
+ expect(missGetSpan).toBeDefined();
+
+ // Without span streaming, the key digest doubles as the span description.
+ const cacheKeyDigest = missGetSpan!.data?.['cache.key'] as string[];
+ expect(cacheKeyDigest).toEqual([expect.stringMatching(/^[0-9a-f]{12}$/)]);
+
+ expect(missGetSpan).toMatchObject({
+ description: cacheKeyDigest[0],
+ origin: 'auto.cache.nextjs',
+ data: expect.objectContaining({
+ 'cache.hit': false,
+ 'cache.operation': 'get',
+ }),
+ });
+
+ const putSpan = missTx.spans?.find(span => span.op === 'cache.put');
+ expect(putSpan).toBeDefined();
+ expect(putSpan).toMatchObject({
+ description: cacheKeyDigest[0],
+ origin: 'auto.cache.nextjs',
+ data: expect.objectContaining({
+ 'cache.key': cacheKeyDigest,
+ 'cache.operation': 'put',
+ }),
+ });
+
+ const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get');
+ expect(hitGetSpan).toBeDefined();
+ expect(hitGetSpan).toMatchObject({
+ description: cacheKeyDigest[0],
+ origin: 'auto.cache.nextjs',
+ data: expect.objectContaining({
+ 'cache.hit': true,
+ 'cache.key': cacheKeyDigest,
+ 'cache.operation': 'get',
+ 'cache.item_age': expect.any(Number),
+ // `cacheLife('hours')` sets `expire` to one day.
+ 'cache.ttl': 86400,
+ 'cache.tags': ['e2e-use-cache-tag'],
+ }),
+ });
+});
+
+test('Should create cache spans for `use cache` inside a rendered page', async ({ request }) => {
+ const id = crypto.randomUUID();
+
+ const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
+ return (
+ transactionEvent.transaction === 'GET /use-cache-page' &&
+ !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === false)
+ );
+ });
+
+ const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
+ return (
+ transactionEvent.transaction === 'GET /use-cache-page' &&
+ !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true)
+ );
+ });
+
+ await request.get(`/use-cache-page?id=${id}`);
+ const missTx = await missTxPromise;
+
+ await request.get(`/use-cache-page?id=${id}`);
+ const hitTx = await hitTxPromise;
+
+ expect(missTx.spans?.some(span => span.op === 'cache.put')).toBe(true);
+
+ const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get');
+ expect(hitGetSpan).toMatchObject({
+ origin: 'auto.cache.nextjs',
+ data: expect.objectContaining({
+ 'cache.hit': true,
+ 'cache.operation': 'get',
+ }),
+ });
+});
+
+test('Should report an expired entry as a miss and refill it', async ({ request }) => {
+ // The dev server serves `use cache` entries past their `expire` limit, so the expiry path only
+ // exists in production builds.
+ test.skip(process.env.TEST_ENV !== 'production', 'Entries only hard-expire in production');
+
+ const id = crypto.randomUUID();
+
+ const fillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
+ return (
+ transactionEvent.transaction === 'GET /api/use-cache-expiring' &&
+ !!transactionEvent.spans?.some(span => span.op === 'cache.put')
+ );
+ });
+
+ const firstResponse = await (await request.get(`/api/use-cache-expiring?id=${id}`)).json();
+ await fillTxPromise;
+
+ // Sleep past the entry's hard `expire` limit (2s), so the next read must discard it.
+ await new Promise(resolve => setTimeout(resolve, 3_000));
+
+ // Registered after the fill transaction was consumed, so it only matches the refill.
+ const refillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
+ return (
+ transactionEvent.transaction === 'GET /api/use-cache-expiring' &&
+ !!transactionEvent.spans?.some(span => span.op === 'cache.put')
+ );
+ });
+
+ const secondResponse = await (await request.get(`/api/use-cache-expiring?id=${id}`)).json();
+ const refillTx = await refillTxPromise;
+
+ // The entry hard-expired, so the cached function ran again.
+ expect(secondResponse.createdAt).not.toBe(firstResponse.createdAt);
+
+ const refillGetSpan = refillTx.spans?.find(span => span.op === 'cache.get');
+ expect(refillGetSpan).toBeDefined();
+ expect(refillGetSpan!.data).toMatchObject({ 'cache.hit': false });
+});
From a4b4b578ebe567c1338e9a510d387dcf2fc394ce Mon Sep 17 00:00:00 2001
From: s1gr1d <32902192+s1gr1d@users.noreply.github.com>
Date: Thu, 10 Sep 2026 14:37:23 +0200
Subject: [PATCH 2/2] add .fail
---
.../tests/useCacheSpans.spec.ts | 52 +++++++------------
1 file changed, 19 insertions(+), 33 deletions(-)
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/useCacheSpans.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/useCacheSpans.spec.ts
index 47502f18e989..b9232e9df611 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/useCacheSpans.spec.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/useCacheSpans.spec.ts
@@ -1,28 +1,26 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
-test('Should create cache spans around `use cache` functions', async ({ request }) => {
+// The SDK does not instrument Next.js' `use cache` handler yet, so these tests are declared with `test.fail()`
+
+test.fail('Should create cache spans around `use cache` functions', async ({ request }) => {
// A fresh id makes the first request a guaranteed cache miss (the id is part of the cache key)
// even when the test is retried against the same server.
const id = crypto.randomUUID();
const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
- return (
- transactionEvent.transaction === 'GET /api/use-cache' &&
- !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === false)
- );
- });
-
- const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
- return (
- transactionEvent.transaction === 'GET /api/use-cache' &&
- !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true)
- );
+ return transactionEvent.transaction === 'GET /api/use-cache';
});
const firstResponse = await (await request.get(`/api/use-cache?id=${id}`)).json();
const missTx = await missTxPromise;
+ // Registered after the first transaction was consumed, so it only matches the cached read.
+ const hitTxPromise = waitForTransaction(
+ 'nextjs-16-cacheComponents',
+ transactionEvent => transactionEvent.transaction === 'GET /api/use-cache',
+ );
+
const secondResponse = await (await request.get(`/api/use-cache?id=${id}`)).json();
const hitTx = await hitTxPromise;
@@ -74,26 +72,20 @@ test('Should create cache spans around `use cache` functions', async ({ request
});
});
-test('Should create cache spans for `use cache` inside a rendered page', async ({ request }) => {
+test.fail('Should create cache spans for `use cache` inside a rendered page', async ({ request }) => {
const id = crypto.randomUUID();
const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
- return (
- transactionEvent.transaction === 'GET /use-cache-page' &&
- !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === false)
- );
- });
-
- const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
- return (
- transactionEvent.transaction === 'GET /use-cache-page' &&
- !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true)
- );
+ return transactionEvent.transaction === 'GET /use-cache-page';
});
await request.get(`/use-cache-page?id=${id}`);
const missTx = await missTxPromise;
+ const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
+ return transactionEvent.transaction === 'GET /use-cache-page';
+ });
+
await request.get(`/use-cache-page?id=${id}`);
const hitTx = await hitTxPromise;
@@ -109,7 +101,7 @@ test('Should create cache spans for `use cache` inside a rendered page', async (
});
});
-test('Should report an expired entry as a miss and refill it', async ({ request }) => {
+test.fail('Should report an expired entry as a miss and refill it', async ({ request }) => {
// The dev server serves `use cache` entries past their `expire` limit, so the expiry path only
// exists in production builds.
test.skip(process.env.TEST_ENV !== 'production', 'Entries only hard-expire in production');
@@ -117,10 +109,7 @@ test('Should report an expired entry as a miss and refill it', async ({ request
const id = crypto.randomUUID();
const fillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
- return (
- transactionEvent.transaction === 'GET /api/use-cache-expiring' &&
- !!transactionEvent.spans?.some(span => span.op === 'cache.put')
- );
+ return transactionEvent.transaction === 'GET /api/use-cache-expiring';
});
const firstResponse = await (await request.get(`/api/use-cache-expiring?id=${id}`)).json();
@@ -131,10 +120,7 @@ test('Should report an expired entry as a miss and refill it', async ({ request
// Registered after the fill transaction was consumed, so it only matches the refill.
const refillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
- return (
- transactionEvent.transaction === 'GET /api/use-cache-expiring' &&
- !!transactionEvent.spans?.some(span => span.op === 'cache.put')
- );
+ return transactionEvent.transaction === 'GET /api/use-cache-expiring';
});
const secondResponse = await (await request.get(`/api/use-cache-expiring?id=${id}`)).json();