Skip to content

Commit dc4a92f

Browse files
committed
feat: added pending wrapper to abstract pending state management
1 parent b436589 commit dc4a92f

11 files changed

+288
-222
lines changed

packages/next/src/build/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import '../server/require-hook'
2121
import '../server/node-polyfill-fetch'
2222
import '../server/node-polyfill-crypto'
2323
import '../server/node-environment'
24+
import '../lib/polyfill-promise-with-resolvers'
2425

2526
import { green, yellow, red, cyan, bold, underline } from '../lib/picocolors'
2627
import getGzipSize from 'next/dist/compiled/gzip-size'

packages/next/src/export/worker.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
import '../server/node-polyfill-fetch'
1212
import '../server/node-polyfill-web-streams'
1313
import '../server/node-environment'
14+
import '../lib/polyfill-promise-with-resolvers'
1415

1516
process.env.NEXT_IS_EXPORT_WORKER = 'true'
1617

packages/next/src/lib/batcher.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// This takes advantage of `Promise.withResolvers` which is polyfilled in
2+
// this imported module.
3+
import './polyfill-promise-with-resolvers'
4+
5+
import { SchedulerFn } from '../server/lib/schedule-on-next-tick'
6+
7+
type CacheKeyFn<K, C extends string | number | null> = (
8+
key: K
9+
) => PromiseLike<C> | C
10+
11+
type BatcherOptions<K, C extends string | number | null> = {
12+
cacheKeyFn?: CacheKeyFn<K, C>
13+
schedulerFn?: SchedulerFn<void>
14+
}
15+
16+
type WorkFn<V, C> = (
17+
resolve: (value: V | PromiseLike<V>) => void,
18+
key: C
19+
) => Promise<V>
20+
21+
/**
22+
* A wrapper for a function that will only allow one call to the function to
23+
* execute at a time.
24+
*/
25+
export class Batcher<K, V, C extends string | number | null> {
26+
private readonly pending = new Map<C, Promise<V>>()
27+
28+
protected constructor(
29+
private readonly cacheKeyFn?: CacheKeyFn<K, C>,
30+
/**
31+
* A function that will be called to schedule the wrapped function to be
32+
* executed. This defaults to a function that will execute the function
33+
* immediately.
34+
*/
35+
private readonly schedulerFn: SchedulerFn<void> = (fn) => fn()
36+
) {}
37+
38+
/**
39+
* Creates a new instance of PendingWrapper. If the key extends a string or
40+
* number, the key will be used as the cache key. If the key is an object, a
41+
* cache key function must be provided.
42+
*/
43+
public static create<K extends string | number | null, V>(
44+
options?: BatcherOptions<K, K>
45+
): Batcher<K, V, K>
46+
public static create<K, V, C extends string | number | null>(
47+
options: BatcherOptions<K, C> &
48+
Required<Pick<BatcherOptions<K, C>, 'cacheKeyFn'>>
49+
): Batcher<K, V, C>
50+
public static create<K, V, C extends string | number | null>(
51+
options?: BatcherOptions<K, C>
52+
): Batcher<K, V, C> {
53+
return new Batcher<K, V, C>(options?.cacheKeyFn, options?.schedulerFn)
54+
}
55+
56+
/**
57+
* Wraps a function in a promise that will be resolved or rejected only once
58+
* for a given key. This will allow multiple calls to the function to be
59+
* made, but only one will be executed at a time. The result of the first
60+
* call will be returned to all callers.
61+
*
62+
* @param key the key to use for the cache
63+
* @param fn the function to wrap
64+
* @returns a promise that resolves to the result of the function
65+
*/
66+
public async batch(key: K, fn: WorkFn<V, C>): Promise<V> {
67+
const cacheKey = (this.cacheKeyFn ? await this.cacheKeyFn(key) : key) as C
68+
if (cacheKey === null) {
69+
return fn(Promise.resolve, cacheKey)
70+
}
71+
72+
const pending = this.pending.get(cacheKey)
73+
if (pending) return pending
74+
75+
const { promise, resolve, reject } = Promise.withResolvers<V>()
76+
this.pending.set(cacheKey, promise)
77+
78+
this.schedulerFn(async () => {
79+
try {
80+
const result = await fn(resolve, cacheKey)
81+
82+
// Resolving a promise multiple times is a no-op, so we can safely
83+
// resolve all pending promises with the same result.
84+
resolve(result)
85+
} catch (err) {
86+
reject(err)
87+
} finally {
88+
this.pending.delete(cacheKey)
89+
}
90+
})
91+
92+
return promise
93+
}
94+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// This adds a `Promise.withResolvers` polyfill. This will soon be adopted into
2+
// the spec.
3+
//
4+
// TODO: remove this polyfill when it is adopted into the spec.
5+
//
6+
// https://tc39.es/proposal-promise-with-resolvers/
7+
//
8+
if (
9+
!('withResolvers' in Promise) ||
10+
typeof Promise.withResolvers !== 'function'
11+
) {
12+
Promise.withResolvers = <T>() => {
13+
let resolvers: {
14+
resolve: (value: T | PromiseLike<T>) => void
15+
reject: (reason: any) => void
16+
}
17+
18+
// Create the promise and assign the resolvers to the object.
19+
const promise = new Promise<T>((resolve, reject) => {
20+
resolvers = { resolve, reject }
21+
})
22+
23+
// We know that resolvers is defined because the Promise constructor runs
24+
// synchronously.
25+
return { promise, resolve: resolvers!.resolve, reject: resolvers!.reject }
26+
}
27+
}

packages/next/src/server/dev/on-demand-entry-handler.ts

Lines changed: 29 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import HotReloader from './hot-reloader-webpack'
3939
import { isAppPageRouteDefinition } from '../future/route-definitions/app-page-route-definition'
4040
import { scheduleOnNextTick } from '../lib/schedule-on-next-tick'
4141
import { RouteDefinition } from '../future/route-definitions/route-definition'
42+
import { Batcher } from '../../lib/batcher'
4243

4344
const debug = origDebug('next:on-demand-entry-handler')
4445

@@ -878,8 +879,28 @@ export function onDemandEntryHandler({
878879
}
879880
}
880881

882+
type EnsurePageOptions = {
883+
page: string
884+
clientOnly: boolean
885+
appPaths?: ReadonlyArray<string> | null
886+
match?: RouteMatch
887+
isApp?: boolean
888+
}
889+
881890
// Make sure that we won't have multiple invalidations ongoing concurrently.
882-
const curEnsurePage = new Map<string, Promise<void>>()
891+
const batcher = Batcher.create<EnsurePageOptions, void, string>({
892+
// The cache key here is composed of the elements that affect the
893+
// compilation, namely, the page, whether it's client only, and whether
894+
// it's an app page. This ensures that we don't have multiple compilations
895+
// for the same page happening concurrently.
896+
//
897+
// We don't include the whole match because it contains match specific
898+
// parameters (like route params) that would just bust this cache. Any
899+
// details that would possibly bust the cache should be listed here.
900+
cacheKeyFn: (options) => JSON.stringify(options),
901+
// Schedule the invocation of the ensurePageImpl function on the next tick.
902+
schedulerFn: scheduleOnNextTick,
903+
})
883904

884905
return {
885906
async ensurePage({
@@ -888,13 +909,7 @@ export function onDemandEntryHandler({
888909
appPaths = null,
889910
match,
890911
isApp,
891-
}: {
892-
page: string
893-
clientOnly: boolean
894-
appPaths?: ReadonlyArray<string> | null
895-
match?: RouteMatch
896-
isApp?: boolean
897-
}) {
912+
}: EnsurePageOptions) {
898913
// If the route is actually an app page route, then we should have access
899914
// to the app route match, and therefore, the appPaths from it.
900915
if (
@@ -905,43 +920,15 @@ export function onDemandEntryHandler({
905920
appPaths = match.definition.appPaths
906921
}
907922

908-
// The cache key here is composed of the elements that affect the
909-
// compilation, namely, the page, whether it's client only, and whether
910-
// it's an app page. This ensures that we don't have multiple compilations
923+
// Wrap the invocation of the ensurePageImpl function in the pending
924+
// wrapper, which will ensure that we don't have multiple compilations
911925
// for the same page happening concurrently.
912-
//
913-
// We don't include the whole match because it contains match specific
914-
// parameters (like route params) that would just bust this cache. Any
915-
// details that would possibly bust the cache should be listed here.
916-
const key = JSON.stringify({
917-
page,
918-
clientOnly,
919-
appPaths,
920-
definition: match?.definition,
921-
isApp,
922-
})
923-
924-
// See if we're already building this page.
925-
const pending = curEnsurePage.get(key)
926-
if (pending) return pending
927-
928-
const { promise, resolve, reject } = Promise.withResolvers<void>()
929-
curEnsurePage.set(key, promise)
930-
931-
// Schedule the build to occur on the next tick, but don't wait and
932-
// instead return the promise immediately.
933-
scheduleOnNextTick(async () => {
934-
try {
926+
return batcher.batch(
927+
{ page, clientOnly, appPaths, match, isApp },
928+
async () => {
935929
await ensurePageImpl({ page, clientOnly, appPaths, match, isApp })
936-
resolve()
937-
} catch (err) {
938-
reject(err)
939-
} finally {
940-
curEnsurePage.delete(key)
941930
}
942-
})
943-
944-
return promise
931+
)
945932
},
946933
onHMR(client: ws, getHmrServerError: () => Error | null) {
947934
let bufferedHmrServerError: Error | null = null

packages/next/src/server/dev/static-paths-worker.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { NextConfigComplete } from '../config-shared'
33
import '../require-hook'
44
import '../node-polyfill-fetch'
55
import '../node-environment'
6+
import '../lib/polyfill-promise-with-resolvers'
67

78
import {
89
buildAppStaticPaths,

packages/next/src/server/lib/router-server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { WorkerRequestHandler, WorkerUpgradeHandler } from './types'
77
import '../node-polyfill-fetch'
88
import '../node-environment'
99
import '../require-hook'
10+
import './polyfill-promise-with-resolvers'
1011

1112
import url from 'url'
1213
import path from 'path'

packages/next/src/server/lib/schedule-on-next-tick.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
type ScheduledFn<T = void> = () => T | PromiseLike<T>
1+
export type ScheduledFn<T = void> = () => T | PromiseLike<T>
2+
export type SchedulerFn<T = void> = (cb: ScheduledFn<T>) => void
23

34
/**
45
* Schedules a function to be called on the next tick after the other promises
56
* have been resolved.
67
*/
7-
export function scheduleOnNextTick<T = void>(cb: ScheduledFn<T>): void {
8+
export const scheduleOnNextTick = <T = void>(cb: ScheduledFn<T>): void => {
89
// We use Promise.resolve().then() here so that the operation is scheduled at
910
// the end of the promise job queue, we then add it to the next process tick
1011
// to ensure it's evaluated afterwards.

packages/next/src/server/next-server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import './node-polyfill-fetch'
44
import './node-polyfill-form'
55
import './node-polyfill-web-streams'
66
import './node-polyfill-crypto'
7+
import '../lib/polyfill-promise-with-resolvers'
78

89
import type { TLSSocket } from 'tls'
910
import {

packages/next/src/server/node-environment.ts

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,31 +14,3 @@ if (typeof (globalThis as any).WebSocket !== 'function') {
1414
},
1515
})
1616
}
17-
18-
// This adds a `Promise.withResolvers` polyfill. This will soon be adopted into
19-
// the spec.
20-
//
21-
// TODO: remove this polyfill when it is adopted into the spec.
22-
//
23-
// https://tc39.es/proposal-promise-with-resolvers/
24-
//
25-
if (
26-
!('withResolvers' in Promise) ||
27-
typeof Promise.withResolvers !== 'function'
28-
) {
29-
Promise.withResolvers = <T>() => {
30-
let resolvers: {
31-
resolve: (value: T | PromiseLike<T>) => void
32-
reject: (reason: any) => void
33-
}
34-
35-
// Create the promise and assign the resolvers to the object.
36-
const promise = new Promise<T>((resolve, reject) => {
37-
resolvers = { resolve, reject }
38-
})
39-
40-
// We know that resolvers is defined because the Promise constructor runs
41-
// synchronously.
42-
return { promise, resolve: resolvers!.resolve, reject: resolvers!.reject }
43-
}
44-
}

0 commit comments

Comments
 (0)