Skip to content

Commit b145593

Browse files
authored
Fix console replaying and React.cache usage in "use cache" functions (#75520)
Console logs in server components are replayed in the browser. For example, when you run `console.log('foo')`, the log will be replayed in the browser as `[Server] foo`. When the component is inside of a `"use cache"` scope, it's replayed as `[Cache] foo`. However, when logging directly in the function body of a `"use cache"` function, we are currently not replaying the log in the browser. The reason for that is that the function is called outside of React's rendering, before handing the result promise over to React for serialization. Since the function is called without React's request storage, no console chunks are emitted. We can work around this by invoking the function lazily when React calls `.then()` on the promise. This ensures that the function is run inside of React's request storage and console chunks can be emitted. In addition, this also unlocks that `React.cache` can be used in a `"use cache"` function to dedupe other function calls. closes NAR-83
1 parent b0706c3 commit b145593

File tree

5 files changed

+168
-35
lines changed

5 files changed

+168
-35
lines changed

packages/next/src/server/use-cache/use-cache-wrapper.ts

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ function generateCacheEntry(
6060
outerWorkUnitStore: WorkUnitStore | undefined,
6161
clientReferenceManifest: DeepReadonly<ClientReferenceManifestForRsc>,
6262
encodedArguments: FormData | string,
63-
fn: any,
63+
fn: (...args: unknown[]) => Promise<unknown>,
6464
timeoutError: UseCacheTimeoutError
6565
): Promise<[ReadableStream, Promise<CacheEntry>]> {
6666
// We need to run this inside a clean AsyncLocalStorage snapshot so that the cache
@@ -84,7 +84,7 @@ function generateCacheEntryWithRestoredWorkStore(
8484
outerWorkUnitStore: WorkUnitStore | undefined,
8585
clientReferenceManifest: DeepReadonly<ClientReferenceManifestForRsc>,
8686
encodedArguments: FormData | string,
87-
fn: any,
87+
fn: (...args: unknown[]) => Promise<unknown>,
8888
timeoutError: UseCacheTimeoutError
8989
) {
9090
// Since we cleared the AsyncLocalStorage we need to restore the workStore.
@@ -111,7 +111,7 @@ function generateCacheEntryWithCacheContext(
111111
outerWorkUnitStore: WorkUnitStore | undefined,
112112
clientReferenceManifest: DeepReadonly<ClientReferenceManifestForRsc>,
113113
encodedArguments: FormData | string,
114-
fn: any,
114+
fn: (...args: unknown[]) => Promise<unknown>,
115115
timeoutError: UseCacheTimeoutError
116116
) {
117117
if (!workStore.cacheLifeProfiles) {
@@ -291,7 +291,7 @@ async function generateCacheEntryImpl(
291291
innerCacheStore: UseCacheStore,
292292
clientReferenceManifest: DeepReadonly<ClientReferenceManifestForRsc>,
293293
encodedArguments: FormData | string,
294-
fn: any,
294+
fn: (...args: unknown[]) => Promise<unknown>,
295295
timeoutError: UseCacheTimeoutError
296296
): Promise<[ReadableStream, Promise<CacheEntry>]> {
297297
const temporaryReferences = createServerTemporaryReferenceSet()
@@ -335,24 +335,28 @@ async function generateCacheEntryImpl(
335335

336336
// Track the timestamp when we started computing the result.
337337
const startTime = performance.timeOrigin + performance.now()
338-
// Invoke the inner function to load a new result.
339-
const result = fn.apply(null, args)
338+
339+
// Invoke the inner function to load a new result. We delay the invocation
340+
// though, until React awaits the promise so that React's request store (ALS)
341+
// is available when the function is invoked. This allows us, for example, to
342+
// capture logs so that we can later replay them.
343+
const resultPromise = createLazyResult(() => fn.apply(null, args))
340344

341345
let errors: Array<unknown> = []
342346

343347
let timer = undefined
344348
const controller = new AbortController()
345349
if (outerWorkUnitStore?.type === 'prerender') {
346-
// If we're prerendering, we give you 50 seconds to fill a cache entry. Otherwise
347-
// we assume you stalled on hanging input and deopt. This needs to be lower than
348-
// just the general timeout of 60 seconds.
350+
// If we're prerendering, we give you 50 seconds to fill a cache entry.
351+
// Otherwise we assume you stalled on hanging input and de-opt. This needs
352+
// to be lower than just the general timeout of 60 seconds.
349353
timer = setTimeout(() => {
350354
controller.abort(timeoutError)
351355
}, 50000)
352356
}
353357

354358
const stream = renderToReadableStream(
355-
result,
359+
resultPromise,
356360
clientReferenceManifest.clientModules,
357361
{
358362
environmentName: 'Cache',
@@ -488,7 +492,7 @@ export function cache(
488492
kind: string,
489493
id: string,
490494
boundArgsLength: number,
491-
fn: any
495+
fn: (...args: unknown[]) => Promise<unknown>
492496
) {
493497
for (const [key, value] of Object.entries(
494498
cacheHandlerGlobal.__nextCacheHandlers || {}
@@ -819,3 +823,22 @@ export function cache(
819823
}[name]
820824
return cachedFn
821825
}
826+
827+
/**
828+
* Calls the given function only when the returned promise is awaited.
829+
*/
830+
function createLazyResult<TResult>(
831+
fn: () => Promise<TResult>
832+
): PromiseLike<TResult> {
833+
let pendingResult: Promise<TResult> | undefined
834+
835+
return {
836+
then(onfulfilled, onrejected) {
837+
if (!pendingResult) {
838+
pendingResult = fn()
839+
}
840+
841+
return pendingResult.then(onfulfilled, onrejected)
842+
},
843+
}
844+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
async function Bar() {
2+
'use cache'
3+
const date = new Date().toLocaleTimeString()
4+
console.log('deep inside', date)
5+
return <p>{date}</p>
6+
}
7+
8+
async function Foo() {
9+
'use cache'
10+
console.log('inside')
11+
return <Bar />
12+
}
13+
14+
export default async function Page() {
15+
console.log('outside')
16+
17+
return <Foo />
18+
}

test/e2e/app-dir/use-cache/use-cache.test.ts

Lines changed: 79 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { nextTestSetup } from 'e2e-utils'
22
import { retry, waitFor } from 'next-test-utils'
3+
import stripAnsi from 'strip-ansi'
4+
import { format } from 'util'
5+
import { BrowserInterface } from 'next-webdriver'
36

47
const GENERIC_RSC_ERROR =
58
'An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.'
@@ -93,8 +96,7 @@ describe('use-cache', () => {
9396
const browser = await next.browser('/react-cache')
9497
const a = await browser.waitForElementByCss('#a').text()
9598
const b = await browser.waitForElementByCss('#b').text()
96-
// TODO: This is broken. It is expected to pass once we fix it.
97-
expect(a).not.toBe(b)
99+
expect(a).toBe(b)
98100
})
99101

100102
it('should error when cookies/headers/draftMode is used inside "use cache"', async () => {
@@ -313,30 +315,33 @@ describe('use-cache', () => {
313315
})
314316
})
315317

316-
it('should be able to revalidate a page using unstable_expireTag', async () => {
317-
const browser = await next.browser(`/form`)
318-
const time1 = await browser.waitForElementByCss('#t').text()
318+
// TODO(useCache): Re-activate for deploy tests when NAR-85 is resolved.
319+
if (!isNextDeploy) {
320+
it('should be able to revalidate a page using unstable_expireTag', async () => {
321+
const browser = await next.browser(`/form`)
322+
const time1 = await browser.waitForElementByCss('#t').text()
319323

320-
await browser.loadPage(new URL(`/form`, next.url).toString())
324+
await browser.loadPage(new URL(`/form`, next.url).toString())
321325

322-
const time2 = await browser.waitForElementByCss('#t').text()
326+
const time2 = await browser.waitForElementByCss('#t').text()
323327

324-
expect(time1).toBe(time2)
328+
expect(time1).toBe(time2)
325329

326-
await browser.elementByCss('#refresh').click()
330+
await browser.elementByCss('#refresh').click()
327331

328-
await waitFor(500)
332+
await waitFor(500)
329333

330-
const time3 = await browser.waitForElementByCss('#t').text()
334+
const time3 = await browser.waitForElementByCss('#t').text()
331335

332-
expect(time3).not.toBe(time2)
336+
expect(time3).not.toBe(time2)
333337

334-
// Reloading again should ideally be the same value but because the Action seeds
335-
// the cache with real params as the argument it has a different cache key.
336-
// await browser.loadPage(new URL(`/form?c`, next.url).toString())
337-
// const time4 = await browser.waitForElementByCss('#t').text()
338-
// expect(time4).toBe(time3);
339-
})
338+
// Reloading again should ideally be the same value but because the Action seeds
339+
// the cache with real params as the argument it has a different cache key.
340+
// await browser.loadPage(new URL(`/form?c`, next.url).toString())
341+
// const time4 = await browser.waitForElementByCss('#t').text()
342+
// expect(time4).toBe(time3);
343+
})
344+
}
340345

341346
it('should use revalidate config in fetch', async () => {
342347
const browser = await next.browser('/fetch-revalidate')
@@ -475,5 +480,61 @@ describe('use-cache', () => {
475480
)
476481
expect(next.cliOutput).not.toContain('HANGING_PROMISE_REJECTION')
477482
})
483+
484+
it('replays logs from "use cache" functions', async () => {
485+
const browser = await next.browser('/logs')
486+
const initialLogs = await getSanitizedLogs(browser)
487+
488+
// We ignore the logged time string at the end of this message:
489+
const logMessageWithDateRegexp =
490+
/^ Server {3}Cache {3}Cache {2}deep inside /
491+
492+
let logMessageWithCachedDate: string | undefined
493+
494+
await retry(async () => {
495+
// TODO(veil): We might want to show only the original (right-most)
496+
// environment badge when caches are nested.
497+
expect(initialLogs).toMatchObject(
498+
expect.arrayContaining([
499+
' Server outside',
500+
' Server Cache inside',
501+
expect.stringMatching(logMessageWithDateRegexp),
502+
])
503+
)
504+
505+
logMessageWithCachedDate = initialLogs.find((log) =>
506+
logMessageWithDateRegexp.test(log)
507+
)
508+
509+
expect(logMessageWithCachedDate).toBeDefined()
510+
})
511+
512+
// Load the page again and expect the cached logs to be replayed again.
513+
// We're using an explicit `loadPage` instead of `refresh` here, to start
514+
// with an empty set of logs.
515+
await browser.loadPage(await browser.url())
516+
517+
await retry(async () => {
518+
const newLogs = await getSanitizedLogs(browser)
519+
520+
expect(newLogs).toMatchObject(
521+
expect.arrayContaining([
522+
' Server outside',
523+
' Server Cache inside',
524+
logMessageWithCachedDate,
525+
])
526+
)
527+
})
528+
})
478529
}
479530
})
531+
532+
async function getSanitizedLogs(browser: BrowserInterface): Promise<string[]> {
533+
const logs = await browser.log({ includeArgs: true })
534+
535+
return logs.map(({ args }) =>
536+
format(
537+
...args.map((arg) => (typeof arg === 'string' ? stripAnsi(arg) : arg))
538+
)
539+
)
540+
}

test/lib/browsers/base.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,13 @@ export abstract class BrowserInterface<TCurrent = any> {
119119
abstract text(): Promise<string>
120120
abstract getComputedCss(prop: string): Promise<string>
121121
abstract hasElementByCssSelector(selector: string): Promise<boolean>
122-
abstract log(): Promise<{ source: string; message: string }[]>
122+
abstract log<T extends boolean = false>(options?: {
123+
includeArgs?: T
124+
}): Promise<
125+
T extends true
126+
? { source: string; message: string; args: unknown[] }[]
127+
: { source: string; message: string }[]
128+
>
123129
abstract websocketFrames(): Promise<any[]>
124130
abstract url(): Promise<string>
125131
abstract waitForIdleNetwork(): Promise<void>

test/lib/browsers/playwright.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ import {
1212
} from 'playwright'
1313
import path from 'path'
1414

15+
type PageLog = { source: string; message: string; args: unknown[] }
16+
1517
let page: Page
1618
let browser: Browser
1719
let context: BrowserContext
1820
let contextHasJSEnabled: boolean = true
19-
let pageLogs: Array<{ source: string; message: string }> = []
21+
let pageLogs: Array<Promise<PageLog> | PageLog> = []
2022
let websocketFrames: Array<{ payload: string | Buffer }> = []
2123

2224
const tracePlaywright = process.env.TRACE_PLAYWRIGHT
@@ -223,7 +225,12 @@ export class Playwright extends BrowserInterface {
223225

224226
page.on('console', (msg) => {
225227
console.log('browser log:', msg)
226-
pageLogs.push({ source: msg.type(), message: msg.text() })
228+
229+
pageLogs.push(
230+
Promise.all(
231+
msg.args().map((handle) => handle.jsonValue().catch(() => {}))
232+
).then((args) => ({ source: msg.type(), message: msg.text(), args }))
233+
)
227234
})
228235
page.on('crash', () => {
229236
console.error('page crashed')
@@ -232,7 +239,7 @@ export class Playwright extends BrowserInterface {
232239
console.error('page error', error)
233240

234241
if (opts?.pushErrorAsConsoleLog) {
235-
pageLogs.push({ source: 'error', message: error.message })
242+
pageLogs.push({ source: 'error', message: error.message, args: [] })
236243
}
237244
})
238245
page.on('request', (req) => {
@@ -474,8 +481,26 @@ export class Playwright extends BrowserInterface {
474481
return page.evaluate<T>(fn).catch(() => null)
475482
}
476483

477-
async log() {
478-
return this.chain(() => pageLogs)
484+
async log<T extends boolean = false>(options?: {
485+
includeArgs?: T
486+
}): Promise<
487+
T extends true
488+
? { source: string; message: string; args: unknown[] }[]
489+
: { source: string; message: string }[]
490+
> {
491+
return this.chain(
492+
() =>
493+
options?.includeArgs
494+
? Promise.all(pageLogs)
495+
: Promise.all(pageLogs).then((logs) =>
496+
logs.map(({ source, message }) => ({ source, message }))
497+
)
498+
// TODO: Starting with TypeScript 5.8 we might not need this type cast.
499+
) as Promise<
500+
T extends true
501+
? { source: string; message: string; args: unknown[] }[]
502+
: { source: string; message: string }[]
503+
>
479504
}
480505

481506
async websocketFrames() {

0 commit comments

Comments
 (0)