Skip to content

Commit 15dfaa6

Browse files
committed
wip: correctly label IO promises in devtools
1 parent 041d3e1 commit 15dfaa6

File tree

10 files changed

+272
-72
lines changed

10 files changed

+272
-72
lines changed

packages/next/src/server/app-render/app-render.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2803,6 +2803,12 @@ async function renderWithRestartOnCacheMissInDev(
28032803
cacheSignal,
28042804
hangingCacheAbortSignal: hangingCacheAbortController.signal,
28052805
hangingPromiseAbortSignal: initialHangingPromiseController.signal,
2806+
asyncApiPromises: createAsyncApiPromisesInDev(
2807+
initialStageController,
2808+
requestStore.cookies,
2809+
requestStore.mutableCookies,
2810+
requestStore.headers
2811+
),
28062812
dynamicTracking: createDynamicTrackingState(
28072813
false // isDebugDynamicAccesses
28082814
),
@@ -2942,6 +2948,12 @@ async function renderWithRestartOnCacheMissInDev(
29422948
stagedRendering: finalStageController,
29432949
prerenderResumeDataCache: null,
29442950
cacheSignal: null,
2951+
asyncApiPromises: createAsyncApiPromisesInDev(
2952+
initialStageController,
2953+
requestStore.cookies,
2954+
requestStore.mutableCookies,
2955+
requestStore.headers
2956+
),
29452957
dynamicTracking: createDynamicTrackingState(
29462958
false // isDebugDynamicAccesses
29472959
),
@@ -2992,6 +3004,48 @@ async function renderWithRestartOnCacheMissInDev(
29923004
}
29933005
}
29943006

3007+
function createAsyncApiPromisesInDev(
3008+
stagedRendering: StagedRenderingController,
3009+
cookies: RequestStore['cookies'],
3010+
mutableCookies: RequestStore['mutableCookies'],
3011+
headers: RequestStore['headers']
3012+
): DevRequestStoreModern['asyncApiPromises'] {
3013+
return {
3014+
// Runtime APIs
3015+
cookies: stagedRendering.delayUntilStage(
3016+
RenderStage.Runtime,
3017+
'cookies',
3018+
cookies
3019+
),
3020+
mutableCookies: stagedRendering.delayUntilStage(
3021+
RenderStage.Runtime,
3022+
'cookies',
3023+
mutableCookies as RequestStore['cookies']
3024+
),
3025+
headers: stagedRendering.delayUntilStage(
3026+
RenderStage.Runtime,
3027+
'headers',
3028+
headers
3029+
),
3030+
// These are not used directly, but we chain other `params`/`searchParams` promises off of them.
3031+
sharedParamsParent: stagedRendering.delayUntilStage(
3032+
RenderStage.Runtime,
3033+
'params',
3034+
'<internal params>'
3035+
),
3036+
sharedSearchParamsParent: stagedRendering.delayUntilStage(
3037+
RenderStage.Runtime,
3038+
'searchParams',
3039+
'<internal searchParams>'
3040+
),
3041+
connection: stagedRendering.delayUntilStage(
3042+
RenderStage.Dynamic,
3043+
'connection',
3044+
undefined
3045+
),
3046+
}
3047+
}
3048+
29953049
type DebugChannelPair = {
29963050
serverSide: DebugChannelServer
29973051
clientSide: DebugChannelClient

packages/next/src/server/app-render/staged-rendering.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,17 @@ export class StagedRenderingController {
7171
return this.getStagePromise(stage)
7272
}
7373

74-
delayUntilStage<T>(stage: NonStaticRenderStage, resolvedValue: T) {
74+
delayUntilStage<T>(
75+
stage: NonStaticRenderStage,
76+
displayName: string | undefined,
77+
resolvedValue: T
78+
) {
7579
const stagePromise = this.getStagePromise(stage)
76-
// FIXME: this seems to be the only form that leads to correct API names
77-
// being displayed in React Devtools (in the "suspended by" section).
78-
// If we use `promise.then(() => resolvedValue)`, the names are lost.
79-
// It's a bit strange that only one of those works right.
80-
const promise = new Promise<T>((resolve, reject) => {
81-
stagePromise.then(resolve.bind(null, resolvedValue), reject)
82-
})
80+
const promise = createDevtoolsIOPromise(
81+
stagePromise,
82+
displayName,
83+
resolvedValue
84+
)
8385

8486
// Analogously to `makeHangingPromise`, we might reject this promise if the signal is invoked.
8587
// (e.g. in the case where we don't want want the render to proceed to the dynamic stage and abort it).
@@ -91,4 +93,24 @@ export class StagedRenderingController {
9193
}
9294
}
9395

96+
function createDevtoolsIOPromise<T>(
97+
trigger: Promise<any>,
98+
displayName: string | undefined,
99+
resolvedValue: T
100+
): Promise<T> {
101+
// If we create a `new Promise` and give it a displayName
102+
// (with no userspace code above us in the stack)
103+
// ReactDevtools will use it as the IO cause when determining "suspended by".
104+
// In particular, it should shadow any inner IO that resolved/rejected the promise
105+
// (which in this case will be the `setTimeout` that triggers the relevant stage)
106+
const promise = new Promise<T>((resolve, reject) => {
107+
trigger.then(resolve.bind(null, resolvedValue), reject)
108+
})
109+
if (displayName !== undefined) {
110+
// @ts-expect-error
111+
promise.displayName = displayName
112+
}
113+
return promise
114+
}
115+
94116
function ignoreReject() {}

packages/next/src/server/app-render/work-unit-async-storage.external.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export type DevStoreModernPartial = {
9292
readonly stagedRendering: StagedRenderingController
9393
readonly captureOwnerStack: () => string | null
9494
readonly dynamicTracking: DynamicTrackingState
95+
readonly asyncApiPromises: DevAsyncApiPromises
9596
} & (
9697
| {
9798
// In the initial render, we track and fill caches
@@ -121,6 +122,17 @@ type AllMissing<TObj extends Record<string, any>> = {
121122
[key in keyof TObj]?: undefined
122123
}
123124

125+
type DevAsyncApiPromises = {
126+
cookies: Promise<ReadonlyRequestCookies>
127+
mutableCookies: Promise<ReadonlyRequestCookies>
128+
headers: Promise<ReadonlyHeaders>
129+
130+
sharedParamsParent: Promise<string>
131+
sharedSearchParamsParent: Promise<string>
132+
133+
connection: Promise<undefined>
134+
}
135+
124136
/**
125137
* The Prerender store is for tracking information related to prerenders.
126138
*

packages/next/src/server/dynamic-rendering-utils.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,11 @@ export function makeDevtoolsIOAwarePromise<T>(
8383
): Promise<T> {
8484
if (requestStore.stagedRendering) {
8585
// We resolve each stage in a timeout, so React DevTools will pick this up as IO.
86-
return requestStore.stagedRendering.delayUntilStage(stage, underlying)
86+
return requestStore.stagedRendering.delayUntilStage(
87+
stage,
88+
undefined,
89+
underlying
90+
)
8791
}
8892
// in React DevTools if we resolve in a setTimeout we will observe
8993
// the promise resolution as something that can suspend a boundary or root.

packages/next/src/server/request/connection.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ export function connection(): Promise<void> {
106106
// Semantically we only need the dev tracking when running in `next dev`
107107
// but since you would never use next dev with production NODE_ENV we use this
108108
// as a proxy so we can statically exclude this code from production builds.
109+
if (workUnitStore.asyncApiPromises) {
110+
return workUnitStore.asyncApiPromises.connection
111+
}
109112
return makeDevtoolsIOAwarePromise(
110113
undefined,
111114
workUnitStore,

packages/next/src/server/request/cookies.ts

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,22 @@ function makeUntrackedCookiesWithDevWarnings(
190190
underlyingCookies: ReadonlyRequestCookies,
191191
route?: string
192192
): Promise<ReadonlyRequestCookies> {
193+
if (requestStore.asyncApiPromises) {
194+
let promise: Promise<ReadonlyRequestCookies>
195+
if (underlyingCookies === requestStore.mutableCookies) {
196+
promise = requestStore.asyncApiPromises.mutableCookies
197+
} else if (underlyingCookies === requestStore.cookies) {
198+
promise = requestStore.asyncApiPromises.cookies
199+
} else {
200+
throw new InvariantError(
201+
'Received a underlying cookies object that does not match either `cookies` or `mutableCookies`'
202+
)
203+
}
204+
// TODO(restart-on-cache-miss): Instrument with warnings
205+
// return instrumentCookiesPromiseWithDevWarnings(promise, route)
206+
return promise
207+
}
208+
193209
const cachedCookies = CachedCookies.get(underlyingCookies)
194210
if (cachedCookies) {
195211
return cachedCookies
@@ -201,7 +217,22 @@ function makeUntrackedCookiesWithDevWarnings(
201217
RenderStage.Runtime
202218
)
203219

204-
const proxiedPromise = new Proxy(promise, {
220+
const proxiedPromise = instrumentCookiesPromiseWithDevWarnings(promise, route)
221+
222+
CachedCookies.set(underlyingCookies, proxiedPromise)
223+
224+
return proxiedPromise
225+
}
226+
227+
const warnForSyncAccess = createDedupedByCallsiteServerErrorLoggerDev(
228+
createCookiesAccessError
229+
)
230+
231+
function instrumentCookiesPromiseWithDevWarnings(
232+
promise: Promise<ReadonlyRequestCookies>,
233+
route: string | undefined
234+
) {
235+
return new Proxy(promise, {
205236
get(target, prop, receiver) {
206237
switch (prop) {
207238
case Symbol.iterator: {
@@ -226,17 +257,12 @@ function makeUntrackedCookiesWithDevWarnings(
226257

227258
return ReflectAdapter.get(target, prop, receiver)
228259
},
260+
set(target, prop, newValue, receiver) {
261+
return ReflectAdapter.set(target, prop, newValue, receiver)
262+
},
229263
})
230-
231-
CachedCookies.set(underlyingCookies, proxiedPromise)
232-
233-
return proxiedPromise
234264
}
235265

236-
const warnForSyncAccess = createDedupedByCallsiteServerErrorLoggerDev(
237-
createCookiesAccessError
238-
)
239-
240266
function createCookiesAccessError(
241267
route: string | undefined,
242268
expression: string

packages/next/src/server/request/headers.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,13 @@ function makeUntrackedHeadersWithDevWarnings(
199199
route: string | undefined,
200200
requestStore: RequestStore
201201
): Promise<ReadonlyHeaders> {
202+
if (requestStore.asyncApiPromises) {
203+
const promise = requestStore.asyncApiPromises.headers
204+
// TODO(restart-on-cache-miss): Instrument with warnings
205+
// return instrumentHeadersPromiseWithDevWarnings(promise, route)
206+
return promise
207+
}
208+
202209
const cachedHeaders = CachedHeaders.get(underlyingHeaders)
203210
if (cachedHeaders) {
204211
return cachedHeaders
@@ -210,7 +217,22 @@ function makeUntrackedHeadersWithDevWarnings(
210217
RenderStage.Runtime
211218
)
212219

213-
const proxiedPromise = new Proxy(promise, {
220+
const proxiedPromise = instrumentHeadersPromiseWithDevWarnings(promise, route)
221+
222+
CachedHeaders.set(underlyingHeaders, proxiedPromise)
223+
224+
return proxiedPromise
225+
}
226+
227+
const warnForSyncAccess = createDedupedByCallsiteServerErrorLoggerDev(
228+
createHeadersAccessError
229+
)
230+
231+
function instrumentHeadersPromiseWithDevWarnings(
232+
promise: Promise<ReadonlyHeaders>,
233+
route: string | undefined
234+
) {
235+
return new Proxy(promise, {
214236
get(target, prop, receiver) {
215237
switch (prop) {
216238
case Symbol.iterator: {
@@ -237,17 +259,12 @@ function makeUntrackedHeadersWithDevWarnings(
237259

238260
return ReflectAdapter.get(target, prop, receiver)
239261
},
262+
set(target, prop, newValue, receiver) {
263+
return ReflectAdapter.set(target, prop, newValue, receiver)
264+
},
240265
})
241-
242-
CachedHeaders.set(underlyingHeaders, proxiedPromise)
243-
244-
return proxiedPromise
245266
}
246267

247-
const warnForSyncAccess = createDedupedByCallsiteServerErrorLoggerDev(
248-
createHeadersAccessError
249-
)
250-
251268
function createHeadersAccessError(
252269
route: string | undefined,
253270
expression: string

packages/next/src/server/request/params.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,19 @@ function makeDynamicallyTrackedParamsWithDevWarnings(
455455
workStore: WorkStore,
456456
requestStore: RequestStore
457457
): Promise<Params> {
458+
if (requestStore.asyncApiPromises && hasFallbackParams) {
459+
const promise = requestStore.asyncApiPromises.sharedParamsParent.then(
460+
() => underlyingParams
461+
)
462+
// TODO(restart-on-cache-miss): Instrument with warnings
463+
// return instrumentParamsPromiseWithDevWarnings(
464+
// underlyingParams,
465+
// promise,
466+
// workStore
467+
// )
468+
return promise
469+
}
470+
458471
const cachedParams = CachedParams.get(underlyingParams)
459472
if (cachedParams) {
460473
return cachedParams
@@ -472,6 +485,20 @@ function makeDynamicallyTrackedParamsWithDevWarnings(
472485
: // We don't want to force an environment transition when this params is not part of the fallback params set
473486
Promise.resolve(underlyingParams)
474487

488+
const proxiedPromise = instrumentParamsPromiseWithDevWarnings(
489+
underlyingParams,
490+
promise,
491+
workStore
492+
)
493+
CachedParams.set(underlyingParams, proxiedPromise)
494+
return proxiedPromise
495+
}
496+
497+
function instrumentParamsPromiseWithDevWarnings(
498+
underlyingParams: Params,
499+
promise: Promise<Params>,
500+
workStore: WorkStore
501+
): Promise<Params> {
475502
// Track which properties we should warn for.
476503
const proxiedProperties = new Set<string>()
477504

@@ -484,7 +511,7 @@ function makeDynamicallyTrackedParamsWithDevWarnings(
484511
}
485512
})
486513

487-
const proxiedPromise = new Proxy(promise, {
514+
return new Proxy(promise, {
488515
get(target, prop, receiver) {
489516
if (typeof prop === 'string') {
490517
if (
@@ -509,9 +536,6 @@ function makeDynamicallyTrackedParamsWithDevWarnings(
509536
return Reflect.ownKeys(target)
510537
},
511538
})
512-
513-
CachedParams.set(underlyingParams, proxiedPromise)
514-
return proxiedPromise
515539
}
516540

517541
const warnForSyncAccess = createDedupedByCallsiteServerErrorLoggerDev(

0 commit comments

Comments
 (0)