Skip to content

Commit 5f3d028

Browse files
committed
Enhance the build-manifest plugin to account for the app dir structure
Previously it contained entries for layouts and parallel routes, this confused the 'unique files' computation used to compute 'First Load JS' metrics.
1 parent e5faab3 commit 5f3d028

File tree

2 files changed

+524
-7
lines changed

2 files changed

+524
-7
lines changed
Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
// Mock webpack sources
2+
jest.mock('next/dist/compiled/webpack/webpack', () => ({
3+
webpack: {},
4+
sources: {
5+
RawSource: jest.fn().mockImplementation((json) => ({
6+
source: () => json,
7+
})),
8+
},
9+
}))
10+
11+
// Mock dependencies
12+
jest.mock('../../../shared/lib/constants', () => ({
13+
APP_BUILD_MANIFEST: 'app-build-manifest.json',
14+
CLIENT_STATIC_FILES_RUNTIME_MAIN_APP: 'main-app',
15+
SYSTEM_ENTRYPOINTS: new Set(['main', 'main-app']),
16+
}))
17+
18+
jest.mock('./build-manifest-plugin', () => ({
19+
getEntrypointFiles: jest.fn(),
20+
}))
21+
22+
jest.mock('../../../server/get-app-route-from-entrypoint', () => jest.fn())
23+
24+
jest.mock('../../../lib/is-app-page-route', () => ({
25+
isAppPageRoute: jest.fn(),
26+
}))
27+
28+
jest.mock('../../../lib/is-app-route-route', () => ({
29+
isAppRouteRoute: jest.fn(),
30+
}))
31+
32+
// Import the main plugin after mocks
33+
import { AppBuildManifestPlugin } from './app-build-manifest-plugin'
34+
35+
import { getEntrypointFiles } from './build-manifest-plugin'
36+
import getAppRouteFromEntrypoint from '../../../server/get-app-route-from-entrypoint'
37+
import { isAppPageRoute } from '../../../lib/is-app-page-route'
38+
import { isAppRouteRoute } from '../../../lib/is-app-route-route'
39+
40+
const mockGetEntrypointFiles = getEntrypointFiles as jest.MockedFunction<
41+
typeof getEntrypointFiles
42+
>
43+
const mockGetAppRouteFromEntrypoint =
44+
getAppRouteFromEntrypoint as jest.MockedFunction<
45+
typeof getAppRouteFromEntrypoint
46+
>
47+
const mockIsAppPageRoute = isAppPageRoute as jest.MockedFunction<
48+
typeof isAppPageRoute
49+
>
50+
const mockIsAppRouteRoute = isAppRouteRoute as jest.MockedFunction<
51+
typeof isAppRouteRoute
52+
>
53+
54+
// Mock webpack compilation and entrypoints based on relisten-web debug output
55+
function createMockEntrypoint(name: string, files: string[] = []) {
56+
return {
57+
name,
58+
chunks: [],
59+
getFiles: () => files,
60+
}
61+
}
62+
63+
function createMockCompilation() {
64+
const entrypoints = new Map([
65+
// System entrypoints (should be skipped)
66+
['main', createMockEntrypoint('main')],
67+
[
68+
'main-app',
69+
createMockEntrypoint('main-app', ['static/chunks/main-app-123.js']),
70+
],
71+
72+
// Route entrypoints (should be included)
73+
[
74+
'app/(browse)/page',
75+
createMockEntrypoint('app/(browse)/page', [
76+
'static/chunks/browse-page.js',
77+
]),
78+
],
79+
[
80+
'app/api/status/route',
81+
createMockEntrypoint('app/api/status/route', [
82+
'static/chunks/api-status.js',
83+
]),
84+
],
85+
[
86+
'app/album-art/route',
87+
createMockEntrypoint('app/album-art/route', [
88+
'static/chunks/album-art.js',
89+
]),
90+
],
91+
92+
// Segment entrypoints (should NOT be included as separate entries)
93+
[
94+
'app/layout',
95+
createMockEntrypoint('app/layout', ['static/chunks/layout.js']),
96+
],
97+
[
98+
'app/global-error',
99+
createMockEntrypoint('app/global-error', [
100+
'static/chunks/global-error.js',
101+
]),
102+
],
103+
[
104+
'app/(browse)/layout',
105+
createMockEntrypoint('app/(browse)/layout', [
106+
'static/chunks/browse-layout.js',
107+
]),
108+
],
109+
[
110+
'app/(browse)/@artists/default',
111+
createMockEntrypoint('app/(browse)/@artists/default', [
112+
'static/chunks/artists-default.js',
113+
]),
114+
],
115+
116+
// System built-in entrypoints (should be skipped)
117+
[
118+
'next/dist/client/components/builtin/forbidden',
119+
createMockEntrypoint('next/dist/client/components/builtin/forbidden'),
120+
],
121+
])
122+
123+
return {
124+
entrypoints,
125+
emitAsset: jest.fn(),
126+
}
127+
}
128+
129+
describe('AppBuildManifestPlugin', () => {
130+
let plugin: AppBuildManifestPlugin
131+
let compilation: any
132+
133+
beforeEach(() => {
134+
plugin = new AppBuildManifestPlugin()
135+
compilation = createMockCompilation()
136+
137+
// Reset all mocks
138+
jest.clearAllMocks()
139+
140+
// Setup mock implementations
141+
mockGetEntrypointFiles.mockImplementation((entrypoint) => {
142+
return entrypoint?.getFiles() || []
143+
})
144+
145+
mockGetAppRouteFromEntrypoint.mockImplementation((entryName) => {
146+
if (entryName.startsWith('app/')) {
147+
const route = entryName
148+
.replace(/^app\//, '')
149+
.replace(/(page|route)$/, '')
150+
.replace(/\/$/, '')
151+
return route === '' ? '/' : `/${route}`
152+
}
153+
return null
154+
})
155+
156+
mockIsAppPageRoute.mockImplementation((route) => {
157+
return route.endsWith('/page')
158+
})
159+
160+
mockIsAppRouteRoute.mockImplementation((route) => {
161+
return route.endsWith('/route')
162+
})
163+
})
164+
165+
describe('createAsset', () => {
166+
it('should skip system entrypoints', () => {
167+
// @ts-ignore - accessing private method for testing
168+
plugin.createAsset(compilation)
169+
170+
// Verify system entrypoints are skipped
171+
expect(mockGetAppRouteFromEntrypoint).not.toHaveBeenCalledWith('main')
172+
expect(mockGetAppRouteFromEntrypoint).not.toHaveBeenCalledWith('main-app')
173+
})
174+
175+
it('should skip builtin Next.js components', () => {
176+
// @ts-ignore - accessing private method for testing
177+
plugin.createAsset(compilation)
178+
179+
const [, assetSource] = compilation.emitAsset.mock.calls[0]
180+
const manifest = JSON.parse(assetSource.source())
181+
182+
// Verify builtin components don't appear in final manifest
183+
expect(Object.keys(manifest.pages)).not.toContain('/builtin/forbidden')
184+
})
185+
186+
it('should only include actual routes in manifest', () => {
187+
// Setup route detection mocks
188+
mockGetAppRouteFromEntrypoint.mockImplementation((entryName) => {
189+
switch (entryName) {
190+
case 'app/(browse)/page':
191+
return '/(browse)/page'
192+
case 'app/api/status/route':
193+
return '/api/status/route'
194+
case 'app/album-art/route':
195+
return '/album-art/route'
196+
case 'app/layout':
197+
return '/layout'
198+
case 'app/global-error':
199+
return '/global-error'
200+
case 'app/(browse)/layout':
201+
return '/(browse)/layout'
202+
case 'app/(browse)/@artists/default':
203+
return '/(browse)/@artists/default'
204+
default:
205+
return null
206+
}
207+
})
208+
209+
mockIsAppPageRoute.mockImplementation((route) => {
210+
return route === '/(browse)/page'
211+
})
212+
213+
mockIsAppRouteRoute.mockImplementation((route) => {
214+
return route === '/api/status/route' || route === '/album-art/route'
215+
})
216+
217+
// @ts-ignore - accessing private method for testing
218+
plugin.createAsset(compilation)
219+
220+
const [assetName, assetSource] = compilation.emitAsset.mock.calls[0]
221+
expect(assetName).toBe('app-build-manifest.json')
222+
223+
const manifest = JSON.parse(assetSource.source())
224+
225+
// Should only contain actual routes, not segments (sorted alphabetically)
226+
expect(Object.keys(manifest.pages).sort()).toEqual(
227+
['/album-art/route', '/api/status/route', '/(browse)/page'].sort()
228+
)
229+
})
230+
231+
it('should include main app files and route-specific files', () => {
232+
// Setup the compilation with a route that will be detected
233+
const testCompilation = {
234+
entrypoints: new Map([
235+
[
236+
'main-app',
237+
createMockEntrypoint('main-app', ['static/chunks/main-app-123.js']),
238+
],
239+
[
240+
'app/(browse)/page',
241+
createMockEntrypoint('app/(browse)/page', [
242+
'static/chunks/browse-page.js',
243+
]),
244+
],
245+
]),
246+
emitAsset: jest.fn(),
247+
}
248+
249+
// Setup mocks for this test
250+
mockGetAppRouteFromEntrypoint.mockImplementation((entryName) => {
251+
if (entryName === 'app/(browse)/page') return '/(browse)/page'
252+
return null
253+
})
254+
mockIsAppPageRoute.mockImplementation(
255+
(route) => route === '/(browse)/page'
256+
)
257+
mockIsAppRouteRoute.mockReturnValue(false)
258+
259+
// @ts-ignore - accessing private method for testing
260+
plugin.createAsset(testCompilation)
261+
262+
const [, assetSource] = testCompilation.emitAsset.mock.calls[0]
263+
const manifest = JSON.parse(assetSource.source())
264+
265+
// Check that routes include their main app files and their own files
266+
const browsePageFiles = manifest.pages['/(browse)/page']
267+
268+
// Should include main app files
269+
expect(browsePageFiles).toContain('static/chunks/main-app-123.js')
270+
// Should include the page's own files
271+
expect(browsePageFiles).toContain('static/chunks/browse-page.js')
272+
// Should have at least these core files
273+
expect(browsePageFiles.length).toBeGreaterThanOrEqual(2)
274+
})
275+
276+
it('should sort manifest keys alphabetically', () => {
277+
// Setup multiple routes in non-alphabetical order
278+
const unsortedCompilation = {
279+
entrypoints: new Map([
280+
['main-app', createMockEntrypoint('main-app', ['main-app.js'])],
281+
[
282+
'app/zebra/page',
283+
createMockEntrypoint('app/zebra/page', ['zebra.js']),
284+
],
285+
[
286+
'app/alpha/page',
287+
createMockEntrypoint('app/alpha/page', ['alpha.js']),
288+
],
289+
[
290+
'app/beta/route',
291+
createMockEntrypoint('app/beta/route', ['beta.js']),
292+
],
293+
]),
294+
emitAsset: jest.fn(),
295+
}
296+
297+
mockGetAppRouteFromEntrypoint.mockImplementation((entryName) => {
298+
switch (entryName) {
299+
case 'app/zebra/page':
300+
return '/zebra/page'
301+
case 'app/alpha/page':
302+
return '/alpha/page'
303+
case 'app/beta/route':
304+
return '/beta/route'
305+
default:
306+
return null
307+
}
308+
})
309+
310+
mockIsAppPageRoute.mockImplementation((route) => route.endsWith('/page'))
311+
mockIsAppRouteRoute.mockImplementation((route) =>
312+
route.endsWith('/route')
313+
)
314+
315+
// @ts-ignore - accessing private method for testing
316+
plugin.createAsset(unsortedCompilation)
317+
318+
const [, assetSource] = unsortedCompilation.emitAsset.mock.calls[0]
319+
const manifest = JSON.parse(assetSource.source())
320+
321+
// Keys should be sorted alphabetically
322+
expect(Object.keys(manifest.pages)).toEqual([
323+
'/alpha/page',
324+
'/beta/route',
325+
'/zebra/page',
326+
])
327+
})
328+
})
329+
330+
describe('isSegmentContributingToRoute', () => {
331+
it('should identify layout files as contributing', () => {
332+
// @ts-ignore - accessing private method for testing
333+
const result = plugin.isSegmentContributingToRoute(
334+
'app/(browse)/layout',
335+
['(browse)']
336+
)
337+
expect(result).toBe(true)
338+
})
339+
340+
it('should identify error files as contributing', () => {
341+
// @ts-ignore - accessing private method for testing
342+
const result = plugin.isSegmentContributingToRoute('app/(browse)/error', [
343+
'(browse)',
344+
])
345+
expect(result).toBe(true)
346+
})
347+
348+
it('should identify parallel route defaults as contributing', () => {
349+
// @ts-ignore - accessing private method for testing
350+
const result = plugin.isSegmentContributingToRoute(
351+
'app/(browse)/@artists/default',
352+
['(browse)']
353+
)
354+
expect(result).toBe(true)
355+
})
356+
357+
it('should not identify non-special files as contributing', () => {
358+
// @ts-ignore - accessing private method for testing
359+
const result = plugin.isSegmentContributingToRoute(
360+
'app/(browse)/some-file',
361+
['(browse)']
362+
)
363+
expect(result).toBe(false)
364+
})
365+
366+
it('should not identify segments from different routes as contributing', () => {
367+
// @ts-ignore - accessing private method for testing
368+
const result = plugin.isSegmentContributingToRoute(
369+
'app/(content)/layout',
370+
['(browse)']
371+
)
372+
expect(result).toBe(false)
373+
})
374+
375+
it('should identify root layout as contributing to all routes', () => {
376+
// @ts-ignore - accessing private method for testing
377+
const result = plugin.isSegmentContributingToRoute('app/layout', [
378+
'(browse)',
379+
'artist',
380+
])
381+
expect(result).toBe(true)
382+
})
383+
})
384+
})

0 commit comments

Comments
 (0)