diff --git a/knip.json b/knip.json index 0ca790a46..be860aa2a 100644 --- a/knip.json +++ b/knip.json @@ -34,7 +34,7 @@ }, "playground": { "entry": [ - "{components,layouts,middleware,pages,plugins,server,store}/**", + "{components,composables,layouts,middleware,pages,plugins,server,store}/**", "*.ts" ] } diff --git a/packages/bridge/src/app.ts b/packages/bridge/src/app.ts index c378a8f93..28d8c59c4 100644 --- a/packages/bridge/src/app.ts +++ b/packages/bridge/src/app.ts @@ -106,6 +106,22 @@ export async function setupAppBridge (_options: any) { addTemplate(globalMiddlewareTemplate) + // Expose `$fetch` as a module export so that it can be imported regardless of plugin order + addTemplate({ + filename: 'fetch.mjs', + getContents: () => [ + 'import { $fetch as _$fetch } from \'ofetch\'', + 'if (!globalThis.$fetch) {', + ' globalThis.$fetch = _$fetch', + '}', + 'export const $fetch = globalThis.$fetch' + ].join('\n') + }) + addTemplate({ + filename: 'fetch.d.ts', + getContents: () => 'export declare const $fetch: typeof globalThis.$fetch\n' + }) + addWebpackPlugin(new ProtocolImportPlugin()) // Alias vue3 utilities to vue2 diff --git a/packages/bridge/src/imports/presets.ts b/packages/bridge/src/imports/presets.ts index 011b5557f..5171ed836 100644 --- a/packages/bridge/src/imports/presets.ts +++ b/packages/bridge/src/imports/presets.ts @@ -40,6 +40,10 @@ const granularAppPresets: InlinePreset[] = [ imports: ['useLazyFetch'], from: '#app/composables/fetch' }, + { + imports: ['$fetch'], + from: '#build/fetch.mjs' + }, { imports: ['useCookie'], from: '#app/composables/cookie' diff --git a/packages/bridge/src/runtime/composables/fetch.ts b/packages/bridge/src/runtime/composables/fetch.ts index 1fffbc43b..2ffa83d7f 100644 --- a/packages/bridge/src/runtime/composables/fetch.ts +++ b/packages/bridge/src/runtime/composables/fetch.ts @@ -1,11 +1,15 @@ import type { FetchOptions, FetchError } from 'ofetch' -import type { NitroFetchRequest, TypedInternalResponse, AvailableRouterMethod as _AvailableRouterMethod } from 'nitropack' +import type { $Fetch, NitroFetchRequest, TypedInternalResponse, AvailableRouterMethod as _AvailableRouterMethod } from 'nitropack' import { hash } from 'ohash' import { computed, unref, reactive } from 'vue' import type { Ref } from 'vue' import type { AsyncData, AsyncDataOptions, KeysOf, MultiWatchSources, PickFrom } from './asyncData' import { useAsyncData } from './asyncData' import { useRequestFetch } from './ssr' +// @ts-expect-error virtual file +import { $fetch as _$fetch } from '#build/fetch.mjs' + +const $fetch: $Fetch = _$fetch // support uppercase methods, detail: https://github.com/nuxt/nuxt/issues/22313 type AvailableRouterMethod = _AvailableRouterMethod | Uppercase<_AvailableRouterMethod> @@ -31,7 +35,7 @@ export interface UseFetchOptions< M extends AvailableRouterMethod = AvailableRouterMethod > extends Omit, 'watch'>, ComputedFetchOptions { key?: string - $fetch?: typeof globalThis.$fetch + $fetch?: $Fetch watch?: MultiWatchSources | false } @@ -150,7 +154,7 @@ export function useFetch< controller.signal.onabort = () => clearTimeout(timeoutId) } - let _$fetch = opts.$fetch || globalThis.$fetch + let _$fetch = opts.$fetch || $fetch // Use fetch with request context and headers for server direct API calls if (process.server && !opts.$fetch) { diff --git a/packages/bridge/src/runtime/composables/ssr.ts b/packages/bridge/src/runtime/composables/ssr.ts index deda053bd..be457b76b 100644 --- a/packages/bridge/src/runtime/composables/ssr.ts +++ b/packages/bridge/src/runtime/composables/ssr.ts @@ -1,8 +1,12 @@ import type { H3Event } from 'h3' import type { NuxtAppCompat } from '@nuxt/bridge-schema' import { getRequestHeaders, createEvent } from 'h3' -import type { H3Event$Fetch } from 'nitropack' +import type { $Fetch, H3Event$Fetch } from 'nitropack' import { useNuxtApp } from '../nuxt' +// @ts-expect-error virtual file +import { $fetch as _$fetch } from '#build/fetch.mjs' + +const $fetch: $Fetch = _$fetch export function useRequestHeaders (include: K[]): { [key in Lowercase]?: string } export function useRequestHeaders (): Readonly> @@ -27,9 +31,9 @@ export function useRequestEvent (nuxtApp: NuxtAppCompat = useNuxtApp()): H3Event return nuxtApp.ssrContext._event } -export function useRequestFetch (): H3Event$Fetch | typeof global.$fetch { +export function useRequestFetch (): H3Event$Fetch | $Fetch { if (process.client) { - return globalThis.$fetch + return $fetch } - return useRequestEvent()?.$fetch || globalThis.$fetch + return useRequestEvent()?.$fetch || $fetch } diff --git a/packages/bridge/src/runtime/nitro-bridge.client.mjs b/packages/bridge/src/runtime/nitro-bridge.client.mjs index dedee4910..af3dd773d 100644 --- a/packages/bridge/src/runtime/nitro-bridge.client.mjs +++ b/packages/bridge/src/runtime/nitro-bridge.client.mjs @@ -1,7 +1,4 @@ -import { $fetch } from 'ofetch' - -if (!globalThis.$fetch) { - globalThis.$fetch = $fetch -} +// Ensure `globalThis.$fetch` is set for code that does not import `$fetch` explicitly +import '#build/fetch.mjs' export default () => {} diff --git a/packages/bridge/test/mocks/fetch.mjs b/packages/bridge/test/mocks/fetch.mjs new file mode 100644 index 000000000..0c6db3158 --- /dev/null +++ b/packages/bridge/test/mocks/fetch.mjs @@ -0,0 +1,2 @@ +// Mock for `#build/fetch.mjs`, which is generated by `setupAppBridge` +export { $fetch } from 'ofetch' diff --git a/playground/composables/global-fetch.ts b/playground/composables/global-fetch.ts new file mode 100644 index 000000000..822f142eb --- /dev/null +++ b/playground/composables/global-fetch.ts @@ -0,0 +1,2 @@ +// `$fetch` is used at the top level of a module imported by a layout, which is evaluated before plugins +export const customFetch = $fetch.create({}) diff --git a/playground/layouts/custom.vue b/playground/layouts/custom.vue index ac9ef32e7..e55d523a2 100644 --- a/playground/layouts/custom.vue +++ b/playground/layouts/custom.vue @@ -1,5 +1,6 @@ @@ -14,5 +15,8 @@ const count = ref(0) +
+ customFetch: {{ customFetchType }} +
diff --git a/test/bridge.test.ts b/test/bridge.test.ts index ad1e12094..d6c41ff59 100644 --- a/test/bridge.test.ts +++ b/test/bridge.test.ts @@ -380,6 +380,17 @@ describe('layouts', () => { expect(html).toContain('with-layout.vue') expect(html).toContain('Custom Layout:') }) + + it('should hydrate when a layout uses `$fetch` at the top level of a module', async () => { + const { page, pageErrors } = await renderPage('/with-layout') + + expect(pageErrors).toEqual([]) + expect(await page.locator('.custom-fetch').textContent()).toContain('customFetch: function') + await page.locator('.add-count').click() + await page.waitForFunction(() => document.querySelector('.count')?.textContent?.trim() === '1') + + await page.close() + }) }) describe('nitro plugins', () => { diff --git a/vitest.config.ts b/vitest.config.ts index abed6b211..96a0ee1c7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,3 +1,10 @@ +import { fileURLToPath } from 'url' import { defineConfig } from 'vitest/config' -export default defineConfig({}) +export default defineConfig({ + resolve: { + alias: { + '#build/fetch.mjs': fileURLToPath(new URL('./packages/bridge/test/mocks/fetch.mjs', import.meta.url)) + } + } +})