Skip to content

Commit 135d749

Browse files
Merge branch 'canary' into jrl-server-actions
2 parents 527e831 + ad42b61 commit 135d749

15 files changed

+380
-231
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.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { Batcher } from './batcher'
2+
3+
describe('Batcher', () => {
4+
describe('batch', () => {
5+
it('should execute the work function immediately', async () => {
6+
const batcher = Batcher.create<string, number>()
7+
const workFn = jest.fn().mockResolvedValue(42)
8+
9+
const result = await batcher.batch('key', workFn)
10+
11+
expect(result).toBe(42)
12+
expect(workFn).toHaveBeenCalledTimes(1)
13+
})
14+
15+
it('should batch multiple calls to the same key', async () => {
16+
const batcher = Batcher.create<string, number>()
17+
const workFn = jest.fn().mockResolvedValue(42)
18+
19+
const result1 = batcher.batch('key', workFn)
20+
const result2 = batcher.batch('key', workFn)
21+
22+
expect(result1).toBeInstanceOf(Promise)
23+
expect(result2).toBeInstanceOf(Promise)
24+
expect(workFn).toHaveBeenCalledTimes(1)
25+
26+
const [value1, value2] = await Promise.all([result1, result2])
27+
28+
expect(value1).toBe(42)
29+
expect(value2).toBe(42)
30+
expect(workFn).toHaveBeenCalledTimes(1)
31+
})
32+
33+
it('should not batch calls to different keys', async () => {
34+
const batcher = Batcher.create<string, string>()
35+
const workFn = jest.fn((key) => key)
36+
37+
const result1 = batcher.batch('key1', workFn)
38+
const result2 = batcher.batch('key2', workFn)
39+
40+
expect(result1).toBeInstanceOf(Promise)
41+
expect(result2).toBeInstanceOf(Promise)
42+
expect(workFn).toHaveBeenCalledTimes(2)
43+
44+
const [value1, value2] = await Promise.all([result1, result2])
45+
46+
expect(value1).toBe('key1')
47+
expect(value2).toBe('key2')
48+
expect(workFn).toHaveBeenCalledTimes(2)
49+
})
50+
51+
it('should use the cacheKeyFn to generate cache keys', async () => {
52+
const cacheKeyFn = jest.fn().mockResolvedValue('cache-key')
53+
const batcher = Batcher.create<string, number>({ cacheKeyFn })
54+
const workFn = jest.fn().mockResolvedValue(42)
55+
56+
const result = await batcher.batch('key', workFn)
57+
58+
expect(result).toBe(42)
59+
expect(cacheKeyFn).toHaveBeenCalledWith('key')
60+
expect(workFn).toHaveBeenCalledTimes(1)
61+
})
62+
63+
it('should use the schedulerFn to schedule work', async () => {
64+
const schedulerFn = jest.fn().mockImplementation((fn) => fn())
65+
const batcher = Batcher.create<string, number>({ schedulerFn })
66+
const workFn = jest.fn().mockResolvedValue(42)
67+
68+
const results = await Promise.all([
69+
batcher.batch('key', workFn),
70+
batcher.batch('key', workFn),
71+
batcher.batch('key', workFn),
72+
])
73+
74+
expect(results).toEqual([42, 42, 42])
75+
expect(workFn).toHaveBeenCalledTimes(1)
76+
})
77+
})
78+
})

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+
key: C,
18+
resolve: (value: V | PromiseLike<V>) => void
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(cacheKey, Promise.resolve)
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(cacheKey, resolve)
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+
}

packages/next/src/lib/picocolors.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ export const green = enabled ? formatter('\x1b[32m', '\x1b[39m') : String
6565
export const yellow = enabled ? formatter('\x1b[33m', '\x1b[39m') : String
6666
export const blue = enabled ? formatter('\x1b[34m', '\x1b[39m') : String
6767
export const magenta = enabled ? formatter('\x1b[35m', '\x1b[39m') : String
68+
export const purple = enabled
69+
? formatter('\x1b[38;2;173;127;168m', '\x1b[39m')
70+
: String
6871
export const cyan = enabled ? formatter('\x1b[36m', '\x1b[39m') : String
6972
export const white = enabled ? formatter('\x1b[37m', '\x1b[39m') : String
7073
export const gray = enabled ? formatter('\x1b[90m', '\x1b[39m') : String
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/lib/worker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Worker as JestWorker } from 'next/dist/compiled/jest-worker'
33
import { getNodeOptionsWithoutInspect } from '../server/lib/utils'
44

55
// We need this as we're using `Promise.withResolvers` which is not available in the node typings
6-
import '../server/node-environment'
6+
import '../lib/polyfill-promise-with-resolvers'
77

88
type FarmOptions = ConstructorParameters<typeof JestWorker>[1]
99

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

Lines changed: 35 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,34 @@ 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<
892+
Omit<EnsurePageOptions, 'match'> & {
893+
definition?: RouteDefinition
894+
},
895+
void,
896+
string
897+
>({
898+
// The cache key here is composed of the elements that affect the
899+
// compilation, namely, the page, whether it's client only, and whether
900+
// it's an app page. This ensures that we don't have multiple compilations
901+
// for the same page happening concurrently.
902+
//
903+
// We don't include the whole match because it contains match specific
904+
// parameters (like route params) that would just bust this cache. Any
905+
// details that would possibly bust the cache should be listed here.
906+
cacheKeyFn: (options) => JSON.stringify(options),
907+
// Schedule the invocation of the ensurePageImpl function on the next tick.
908+
schedulerFn: scheduleOnNextTick,
909+
})
883910

884911
return {
885912
async ensurePage({
@@ -888,13 +915,7 @@ export function onDemandEntryHandler({
888915
appPaths = null,
889916
match,
890917
isApp,
891-
}: {
892-
page: string
893-
clientOnly: boolean
894-
appPaths?: ReadonlyArray<string> | null
895-
match?: RouteMatch
896-
isApp?: boolean
897-
}) {
918+
}: EnsurePageOptions) {
898919
// If the route is actually an app page route, then we should have access
899920
// to the app route match, and therefore, the appPaths from it.
900921
if (
@@ -905,43 +926,15 @@ export function onDemandEntryHandler({
905926
appPaths = match.definition.appPaths
906927
}
907928

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
929+
// Wrap the invocation of the ensurePageImpl function in the pending
930+
// wrapper, which will ensure that we don't have multiple compilations
911931
// 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 {
932+
return batcher.batch(
933+
{ page, clientOnly, appPaths, definition: match?.definition, isApp },
934+
async () => {
935935
await ensurePageImpl({ page, clientOnly, appPaths, match, isApp })
936-
resolve()
937-
} catch (err) {
938-
reject(err)
939-
} finally {
940-
curEnsurePage.delete(key)
941936
}
942-
})
943-
944-
return promise
937+
)
945938
},
946939
onHMR(client: ws, getHmrServerError: () => Error | null) {
947940
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 '../../lib/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/lib/start-server.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { formatHostname } from './format-hostname'
1818
import { initialize } from './router-server'
1919
import { checkIsNodeDebugging } from './is-node-debugging'
2020
import { CONFIG_FILES } from '../../shared/lib/constants'
21-
import { bold, magenta } from '../../lib/picocolors'
21+
import { bold, purple } from '../../lib/picocolors'
2222

2323
const debug = setupDebug('next:start-server')
2424

@@ -92,11 +92,7 @@ function logStartInfo({
9292
formatDurationText: string
9393
}) {
9494
Log.bootstrap(
95-
bold(
96-
magenta(
97-
`${`${Log.prefixes.ready} Next.js`} ${process.env.__NEXT_VERSION}`
98-
)
99-
)
95+
bold(purple(`${Log.prefixes.ready} Next.js ${process.env.__NEXT_VERSION}`))
10096
)
10197
Log.bootstrap(`- Local: ${appUrl}`)
10298
if (hostname) {

0 commit comments

Comments
 (0)