Skip to content

Commit 14d22b2

Browse files
authored
fix(server): dedupe remote collection requests (#535)
1 parent 08ee779 commit 14d22b2

2 files changed

Lines changed: 83 additions & 3 deletions

File tree

‎src/bundle-server.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,14 @@ export function registerServerBundle(
9393
' return async () => {',
9494
' if (_cache)',
9595
' return _cache',
96-
' const res = await fetch(fetchEndpoint).then(r => r.json())',
97-
' _cache = res',
98-
' return res',
96+
' _cache = fetch(fetchEndpoint).then(r => r.json()).then((data) => {',
97+
' _cache = data',
98+
' return data',
99+
' }).catch((error) => {',
100+
' _cache = undefined',
101+
' throw error',
102+
' })',
103+
' return _cache',
99104
' }',
100105
'}',
101106
'',

‎test/server-bundle.test.ts‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { addTemplate } from '@nuxt/kit'
2+
import { afterEach, expect, it, vi } from 'vitest'
3+
import { registerServerBundle } from '../src/bundle-server'
4+
import type { NuxtIconModuleContext } from '../src/context'
5+
6+
vi.mock('@nuxt/kit', async importOriginal => ({
7+
...await importOriginal<typeof import('@nuxt/kit')>(),
8+
addTemplate: vi.fn(() => ({ dst: '/nuxt-icon-server-bundle.mjs' })),
9+
}))
10+
11+
afterEach(() => {
12+
vi.unstubAllGlobals()
13+
vi.clearAllMocks()
14+
})
15+
16+
async function loadCollection() {
17+
registerServerBundle({
18+
nuxt: { options: { dev: false, appConfig: {}, nitro: {} } },
19+
resolveServerBundle: async () => ({
20+
collections: [{ prefix: 'test', fetchEndpoint: 'https://example.com/icons.json' }],
21+
remote: false,
22+
}),
23+
} as unknown as NuxtIconModuleContext)
24+
25+
const template = vi.mocked(addTemplate).mock.calls[0]![0] as { getContents: () => Promise<string> }
26+
const code = await template.getContents()
27+
const module = await import(/* @vite-ignore */ `data:text/javascript;base64,${Buffer.from(code).toString('base64')}#${crypto.randomUUID()}`)
28+
return module.collections.test as () => Promise<unknown>
29+
}
30+
31+
it('shares an in-flight collection fetch and caches the resolved data', async () => {
32+
const { promise, resolve } = Promise.withResolvers<Response>()
33+
const fetch = vi.fn(() => promise)
34+
vi.stubGlobal('fetch', fetch)
35+
const load = await loadCollection()
36+
const first = load()
37+
const second = load()
38+
39+
expect(fetch).toHaveBeenCalledTimes(1)
40+
const data = { prefix: 'test', icons: { item: { body: '<path d="M0 0h16v16H0z"/>' } } }
41+
resolve(Response.json(data))
42+
await expect(first).resolves.toEqual(data)
43+
await expect(second).resolves.toEqual(data)
44+
await expect(load()).resolves.toEqual(data)
45+
expect(fetch).toHaveBeenCalledTimes(1)
46+
})
47+
48+
it.each(['fetch', 'json'])('retries after a rejected %s operation', async (failure) => {
49+
const fetch = vi.fn()
50+
if (failure === 'fetch')
51+
fetch.mockRejectedValueOnce(new Error('offline'))
52+
else
53+
fetch.mockResolvedValueOnce(new Response('invalid JSON'))
54+
const data = { prefix: 'test', icons: {} }
55+
fetch.mockResolvedValue(Response.json(data))
56+
vi.stubGlobal('fetch', fetch)
57+
const load = await loadCollection()
58+
59+
await expect(load()).rejects.toThrow()
60+
await expect(load()).resolves.toEqual(data)
61+
expect(fetch).toHaveBeenCalledTimes(2)
62+
})
63+
64+
it.each([null, false])('does not retain a falsy collection response: %s', async (value) => {
65+
const data = { prefix: 'test', icons: {} }
66+
const fetch = vi.fn()
67+
.mockResolvedValueOnce(Response.json(value))
68+
.mockResolvedValue(Response.json(data))
69+
vi.stubGlobal('fetch', fetch)
70+
const load = await loadCollection()
71+
72+
await expect(load()).resolves.toBe(value)
73+
await expect(load()).resolves.toEqual(data)
74+
expect(fetch).toHaveBeenCalledTimes(2)
75+
})

0 commit comments

Comments
 (0)