mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-28 08:32:06 +00:00
Merge remote-tracking branch 'origin/main' into fix/14275-nuxt-3-does-not-respect-layouts-in-separate-folders
This commit is contained in:
commit
2d41400cfb
@ -350,10 +350,6 @@ In this case, the `.server` + `.client` components are two 'halves' of a compone
|
|||||||
</template>
|
</template>
|
||||||
```
|
```
|
||||||
|
|
||||||
::alert{type=warning}
|
|
||||||
It is essential that the client half of the component can 'hydrate' the server-rendered HTML. That is, it should render the same HTML on initial load, or you will experience a hydration mismatch.
|
|
||||||
::
|
|
||||||
|
|
||||||
## `<DevOnly>` Component
|
## `<DevOnly>` Component
|
||||||
|
|
||||||
Nuxt provides the `<DevOnly>` component to render a component only during development.
|
Nuxt provides the `<DevOnly>` component to render a component only during development.
|
||||||
|
@ -26,10 +26,12 @@ type AsyncDataOptions<DataT> = {
|
|||||||
server?: boolean
|
server?: boolean
|
||||||
lazy?: boolean
|
lazy?: boolean
|
||||||
immediate?: boolean
|
immediate?: boolean
|
||||||
|
deep?: boolean
|
||||||
default?: () => DataT | Ref<DataT> | null
|
default?: () => DataT | Ref<DataT> | null
|
||||||
transform?: (input: DataT) => DataT
|
transform?: (input: DataT) => DataT
|
||||||
pick?: string[]
|
pick?: string[]
|
||||||
watch?: WatchSource[]
|
watch?: WatchSource[]
|
||||||
|
getCachedData?: (key: string) => any
|
||||||
}
|
}
|
||||||
|
|
||||||
type AsyncData<DataT, ErrorT> = {
|
type AsyncData<DataT, ErrorT> = {
|
||||||
@ -60,6 +62,8 @@ type AsyncDataRequestStatus = 'idle' | 'pending' | 'success' | 'error'
|
|||||||
* _transform_: a function that can be used to alter `handler` function result after resolving
|
* _transform_: a function that can be used to alter `handler` function result after resolving
|
||||||
* _pick_: only pick specified keys in this array from the `handler` function result
|
* _pick_: only pick specified keys in this array from the `handler` function result
|
||||||
* _watch_: watch reactive sources to auto-refresh
|
* _watch_: watch reactive sources to auto-refresh
|
||||||
|
* _getCachedData_: a function that receives a cache key and can return cached data if it exists (by default it returns `nuxtApp.payload.data[key]` when hydrating and `nuxtApp.static.data[key]` after the app is hydrated). You can use this to build your own custom cache for `useAsyncData`.
|
||||||
|
* _deep_: return data in a deep ref object (it is `true` by default). It can be set to `false` to return data in a shallow ref object, which can improve performance if your data does not need to be deeply reactive.
|
||||||
|
|
||||||
Under the hood, `lazy: false` uses `<Suspense>` to block the loading of the route before the data has been fetched. Consider using `lazy: true` and implementing a loading state instead for a snappier user experience.
|
Under the hood, `lazy: false` uses `<Suspense>` to block the loading of the route before the data has been fetched. Consider using `lazy: true` and implementing a loading state instead for a snappier user experience.
|
||||||
|
|
||||||
|
@ -26,6 +26,8 @@ type UseFetchOptions<DataT> = {
|
|||||||
server?: boolean
|
server?: boolean
|
||||||
lazy?: boolean
|
lazy?: boolean
|
||||||
immediate?: boolean
|
immediate?: boolean
|
||||||
|
getCachedData?: (key: string) => any
|
||||||
|
deep?: boolean
|
||||||
default?: () => DataT
|
default?: () => DataT
|
||||||
transform?: (input: DataT) => DataT
|
transform?: (input: DataT) => DataT
|
||||||
pick?: string[]
|
pick?: string[]
|
||||||
@ -72,6 +74,8 @@ All fetch options can be given a `computed` or `ref` value. These will be watche
|
|||||||
* `transform`: a function that can be used to alter `handler` function result after resolving
|
* `transform`: a function that can be used to alter `handler` function result after resolving
|
||||||
* `pick`: only pick specified keys in this array from the `handler` function result
|
* `pick`: only pick specified keys in this array from the `handler` function result
|
||||||
* `watch`: watch an array of reactive sources and auto-refresh the fetch result when they change. Fetch options and URL are watched by default. You can completely ignore reactive sources by using `watch: false`. Together with `immediate: false`, this allows for a fully-manual `useFetch`.
|
* `watch`: watch an array of reactive sources and auto-refresh the fetch result when they change. Fetch options and URL are watched by default. You can completely ignore reactive sources by using `watch: false`. Together with `immediate: false`, this allows for a fully-manual `useFetch`.
|
||||||
|
* `getCachedData`: a function that receives a cache key and can return cached data if it exists (by default it returns `nuxtApp.payload.data[key]` when hydrating and `nuxtApp.static.data[key]` after the app is hydrated). You can use this to build your own custom cache for `useFetch`.
|
||||||
|
* `deep`: return data in a deep ref object (it is `true` by default). It can be set to `false` to return data in a shallow ref object, which can improve performance if your data does not need to be deeply reactive.
|
||||||
|
|
||||||
::alert{type=warning}
|
::alert{type=warning}
|
||||||
If you provide a function or ref as the `url` parameter, or if you provide functions as arguments to the `options` parameter, then the [`useFetch`](/docs/api/composables/use-fetch) call will not match other [`useFetch`](/docs/api/composables/use-fetch) calls elsewhere in your codebase, even if the options seem to be identical. If you wish to force a match, you may provide your own key in `options`.
|
If you provide a function or ref as the `url` parameter, or if you provide functions as arguments to the `options` parameter, then the [`useFetch`](/docs/api/composables/use-fetch) call will not match other [`useFetch`](/docs/api/composables/use-fetch) calls elsewhere in your codebase, even if the options seem to be identical. If you wish to force a match, you may provide your own key in `options`.
|
||||||
|
@ -24,3 +24,12 @@ Because the data inside [`useState`](/docs/api/composables/use-state) will be se
|
|||||||
|
|
||||||
::ReadMore{link="/docs/getting-started/state-management"}
|
::ReadMore{link="/docs/getting-started/state-management"}
|
||||||
::
|
::
|
||||||
|
|
||||||
|
## Using `shallowRef`
|
||||||
|
|
||||||
|
If you don't need your state to be deeply reactive, you can combine `useState` with [`shallowRef`](https://vuejs.org/api/reactivity-advanced.html#shallowref). This can improve performance when your state contains large objects and arrays.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const state = useState('my-shallow-state', () => shallowRef({ deep: 'not reactive' }))
|
||||||
|
// isShallow(state) === true
|
||||||
|
```
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { createElementBlock, createElementVNode, defineComponent, h, mergeProps, onMounted, ref } from 'vue'
|
import { createElementBlock, createElementVNode, createStaticVNode, defineComponent, getCurrentInstance, h, onMounted, ref } from 'vue'
|
||||||
import type { ComponentOptions } from 'vue'
|
import type { ComponentInternalInstance, ComponentOptions } from 'vue'
|
||||||
|
import { getFragmentHTML } from './utils'
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'ClientOnly',
|
name: 'ClientOnly',
|
||||||
@ -39,7 +40,8 @@ export function createClientOnly<T extends ComponentOptions> (component: T) {
|
|||||||
? createElementVNode(res.type, res.props, res.children, res.patchFlag, res.dynamicProps, res.shapeFlag)
|
? createElementVNode(res.type, res.props, res.children, res.patchFlag, res.dynamicProps, res.shapeFlag)
|
||||||
: h(res)
|
: h(res)
|
||||||
} else {
|
} else {
|
||||||
return h('div', mergeProps(ctx.$attrs ?? ctx._.attrs, { key: 'placeholder-key' }))
|
const fragment = getFragmentHTML(ctx._.vnode.el ?? null)
|
||||||
|
return process.client ? createStaticVNode(fragment.join(''), fragment.length) : h('div', ctx.$attrs ?? ctx._.attrs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (clone.template) {
|
} else if (clone.template) {
|
||||||
@ -51,8 +53,20 @@ export function createClientOnly<T extends ComponentOptions> (component: T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clone.setup = (props, ctx) => {
|
clone.setup = (props, ctx) => {
|
||||||
|
const instance = getCurrentInstance()!
|
||||||
|
|
||||||
|
const attrs = instance.attrs
|
||||||
|
// remove existing directives during hydration
|
||||||
|
const directives = extractDirectives(instance)
|
||||||
|
// prevent attrs inheritance since a staticVNode is rendered before hydration
|
||||||
|
instance.attrs = {}
|
||||||
const mounted$ = ref(false)
|
const mounted$ = ref(false)
|
||||||
onMounted(() => { mounted$.value = true })
|
|
||||||
|
onMounted(() => {
|
||||||
|
instance.attrs = attrs
|
||||||
|
instance.vnode.dirs = directives
|
||||||
|
mounted$.value = true
|
||||||
|
})
|
||||||
|
|
||||||
return Promise.resolve(component.setup?.(props, ctx) || {})
|
return Promise.resolve(component.setup?.(props, ctx) || {})
|
||||||
.then((setupState) => {
|
.then((setupState) => {
|
||||||
@ -65,7 +79,8 @@ export function createClientOnly<T extends ComponentOptions> (component: T) {
|
|||||||
? createElementVNode(res.type, res.props, res.children, res.patchFlag, res.dynamicProps, res.shapeFlag)
|
? createElementVNode(res.type, res.props, res.children, res.patchFlag, res.dynamicProps, res.shapeFlag)
|
||||||
: h(res)
|
: h(res)
|
||||||
} else {
|
} else {
|
||||||
return h('div', mergeProps(ctx.attrs, { key: 'placeholder-key' }))
|
const fragment = getFragmentHTML(instance?.vnode.el ?? null)
|
||||||
|
return process.client ? createStaticVNode(fragment.join(''), fragment.length) : h('div', ctx.attrs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -75,3 +90,10 @@ export function createClientOnly<T extends ComponentOptions> (component: T) {
|
|||||||
|
|
||||||
return clone
|
return clone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractDirectives (instance: ComponentInternalInstance | null) {
|
||||||
|
if (!instance || !instance.vnode.dirs) { return null }
|
||||||
|
const directives = instance.vnode.dirs
|
||||||
|
instance.vnode.dirs = null
|
||||||
|
return directives
|
||||||
|
}
|
||||||
|
@ -104,7 +104,7 @@ export function vforToArray (source: any): any[] {
|
|||||||
* @param withoutSlots purge all slots from the HTML string retrieved
|
* @param withoutSlots purge all slots from the HTML string retrieved
|
||||||
* @returns {string[]} An array of string which represent the content of each element. Use `.join('')` to retrieve a component vnode.el HTML
|
* @returns {string[]} An array of string which represent the content of each element. Use `.join('')` to retrieve a component vnode.el HTML
|
||||||
*/
|
*/
|
||||||
export function getFragmentHTML (element: RendererNode | null, withoutSlots = false) {
|
export function getFragmentHTML (element: RendererNode | null, withoutSlots = false): string[] {
|
||||||
if (element) {
|
if (element) {
|
||||||
if (element.nodeName === '#comment' && element.nodeValue === '[') {
|
if (element.nodeName === '#comment' && element.nodeValue === '[') {
|
||||||
return getFragmentChildren(element, [], withoutSlots)
|
return getFragmentChildren(element, [], withoutSlots)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { getCurrentInstance, onBeforeMount, onServerPrefetch, onUnmounted, ref, toRef, unref, watch } from 'vue'
|
import { getCurrentInstance, onBeforeMount, onServerPrefetch, onUnmounted, ref, shallowRef, toRef, unref, watch } from 'vue'
|
||||||
import type { Ref, WatchSource } from 'vue'
|
import type { Ref, WatchSource } from 'vue'
|
||||||
import type { NuxtApp } from '../nuxt'
|
import type { NuxtApp } from '../nuxt'
|
||||||
import { useNuxtApp } from '../nuxt'
|
import { useNuxtApp } from '../nuxt'
|
||||||
@ -40,10 +40,12 @@ export interface AsyncDataOptions<
|
|||||||
server?: boolean
|
server?: boolean
|
||||||
lazy?: boolean
|
lazy?: boolean
|
||||||
default?: () => DefaultT | Ref<DefaultT>
|
default?: () => DefaultT | Ref<DefaultT>
|
||||||
|
getCachedData?: (key: string) => DataT
|
||||||
transform?: _Transform<ResT, DataT>
|
transform?: _Transform<ResT, DataT>
|
||||||
pick?: PickKeys
|
pick?: PickKeys
|
||||||
watch?: MultiWatchSources
|
watch?: MultiWatchSources
|
||||||
immediate?: boolean
|
immediate?: boolean
|
||||||
|
deep?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AsyncDataExecuteOptions {
|
export interface AsyncDataExecuteOptions {
|
||||||
@ -59,15 +61,14 @@ export interface AsyncDataExecuteOptions {
|
|||||||
export interface _AsyncData<DataT, ErrorT> {
|
export interface _AsyncData<DataT, ErrorT> {
|
||||||
data: Ref<DataT>
|
data: Ref<DataT>
|
||||||
pending: Ref<boolean>
|
pending: Ref<boolean>
|
||||||
refresh: (opts?: AsyncDataExecuteOptions) => Promise<void>
|
refresh: (opts?: AsyncDataExecuteOptions) => Promise<DataT>
|
||||||
execute: (opts?: AsyncDataExecuteOptions) => Promise<void>
|
execute: (opts?: AsyncDataExecuteOptions) => Promise<DataT>
|
||||||
error: Ref<ErrorT | null>
|
error: Ref<ErrorT | null>
|
||||||
status: Ref<AsyncDataRequestStatus>
|
status: Ref<AsyncDataRequestStatus>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AsyncData<Data, Error> = _AsyncData<Data, Error> & Promise<_AsyncData<Data, Error>>
|
export type AsyncData<Data, Error> = _AsyncData<Data, Error> & Promise<_AsyncData<Data, Error>>
|
||||||
|
|
||||||
const getDefault = () => null
|
|
||||||
export function useAsyncData<
|
export function useAsyncData<
|
||||||
ResT,
|
ResT,
|
||||||
DataE = Error,
|
DataE = Error,
|
||||||
@ -131,25 +132,31 @@ export function useAsyncData<
|
|||||||
throw new TypeError('[nuxt] [asyncData] handler must be a function.')
|
throw new TypeError('[nuxt] [asyncData] handler must be a function.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup nuxt instance payload
|
||||||
|
const nuxt = useNuxtApp()
|
||||||
|
|
||||||
|
// Used to get default values
|
||||||
|
const getDefault = () => null
|
||||||
|
const getDefaultCachedData = () => nuxt.isHydrating ? nuxt.payload.data[key] : nuxt.static.data[key]
|
||||||
|
|
||||||
// Apply defaults
|
// Apply defaults
|
||||||
options.server = options.server ?? true
|
options.server = options.server ?? true
|
||||||
options.default = options.default ?? (getDefault as () => DefaultT)
|
options.default = options.default ?? (getDefault as () => DefaultT)
|
||||||
|
options.getCachedData = options.getCachedData ?? getDefaultCachedData
|
||||||
|
|
||||||
options.lazy = options.lazy ?? false
|
options.lazy = options.lazy ?? false
|
||||||
options.immediate = options.immediate ?? true
|
options.immediate = options.immediate ?? true
|
||||||
|
|
||||||
// Setup nuxt instance payload
|
const hasCachedData = () => ![null, undefined].includes(options.getCachedData!(key) as any)
|
||||||
const nuxt = useNuxtApp()
|
|
||||||
|
|
||||||
const getCachedData = () => nuxt.isHydrating ? nuxt.payload.data[key] : nuxt.static.data[key]
|
|
||||||
const hasCachedData = () => getCachedData() !== undefined
|
|
||||||
|
|
||||||
// Create or use a shared asyncData entity
|
// Create or use a shared asyncData entity
|
||||||
if (!nuxt._asyncData[key] || !options.immediate) {
|
if (!nuxt._asyncData[key] || !options.immediate) {
|
||||||
nuxt.payload._errors[key] ??= null
|
nuxt.payload._errors[key] ??= null
|
||||||
|
|
||||||
|
const _ref = options.deep !== true ? shallowRef : ref
|
||||||
|
|
||||||
nuxt._asyncData[key] = {
|
nuxt._asyncData[key] = {
|
||||||
data: ref(getCachedData() ?? options.default!()),
|
data: _ref(options.getCachedData!(key) ?? options.default!()),
|
||||||
pending: ref(!hasCachedData()),
|
pending: ref(!hasCachedData()),
|
||||||
error: toRef(nuxt.payload._errors, key),
|
error: toRef(nuxt.payload._errors, key),
|
||||||
status: ref('idle')
|
status: ref('idle')
|
||||||
@ -163,13 +170,13 @@ export function useAsyncData<
|
|||||||
if (nuxt._asyncDataPromises[key]) {
|
if (nuxt._asyncDataPromises[key]) {
|
||||||
if (opts.dedupe === false) {
|
if (opts.dedupe === false) {
|
||||||
// Avoid fetching same key more than once at a time
|
// Avoid fetching same key more than once at a time
|
||||||
return nuxt._asyncDataPromises[key]
|
return nuxt._asyncDataPromises[key]!
|
||||||
}
|
}
|
||||||
(nuxt._asyncDataPromises[key] as any).cancelled = true
|
(nuxt._asyncDataPromises[key] as any).cancelled = true
|
||||||
}
|
}
|
||||||
// Avoid fetching same key that is already fetched
|
// Avoid fetching same key that is already fetched
|
||||||
if ((opts._initial || (nuxt.isHydrating && opts._initial !== false)) && hasCachedData()) {
|
if ((opts._initial || (nuxt.isHydrating && opts._initial !== false)) && hasCachedData()) {
|
||||||
return getCachedData()
|
return Promise.resolve(options.getCachedData!(key))
|
||||||
}
|
}
|
||||||
asyncData.pending.value = true
|
asyncData.pending.value = true
|
||||||
asyncData.status.value = 'pending'
|
asyncData.status.value = 'pending'
|
||||||
@ -217,7 +224,7 @@ export function useAsyncData<
|
|||||||
delete nuxt._asyncDataPromises[key]
|
delete nuxt._asyncDataPromises[key]
|
||||||
})
|
})
|
||||||
nuxt._asyncDataPromises[key] = promise
|
nuxt._asyncDataPromises[key] = promise
|
||||||
return nuxt._asyncDataPromises[key]
|
return nuxt._asyncDataPromises[key]!
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialFetch = () => asyncData.refresh({ _initial: true })
|
const initialFetch = () => asyncData.refresh({ _initial: true })
|
||||||
@ -230,7 +237,7 @@ export function useAsyncData<
|
|||||||
if (getCurrentInstance()) {
|
if (getCurrentInstance()) {
|
||||||
onServerPrefetch(() => promise)
|
onServerPrefetch(() => promise)
|
||||||
} else {
|
} else {
|
||||||
nuxt.hook('app:created', () => promise)
|
nuxt.hook('app:created', async () => { await promise })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -250,7 +257,7 @@ export function useAsyncData<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fetchOnServer && nuxt.isHydrating && hasCachedData()) {
|
if (asyncData.error.value || (fetchOnServer && nuxt.isHydrating && hasCachedData())) {
|
||||||
// 1. Hydration (server: true): no fetch
|
// 1. Hydration (server: true): no fetch
|
||||||
asyncData.pending.value = false
|
asyncData.pending.value = false
|
||||||
asyncData.status.value = asyncData.error.value ? 'error' : 'success'
|
asyncData.status.value = asyncData.error.value ? 'error' : 'success'
|
||||||
@ -265,9 +272,9 @@ export function useAsyncData<
|
|||||||
if (options.watch) {
|
if (options.watch) {
|
||||||
watch(options.watch, () => asyncData.refresh())
|
watch(options.watch, () => asyncData.refresh())
|
||||||
}
|
}
|
||||||
const off = nuxt.hook('app:data:refresh', (keys) => {
|
const off = nuxt.hook('app:data:refresh', async (keys) => {
|
||||||
if (!keys || keys.includes(key)) {
|
if (!keys || keys.includes(key)) {
|
||||||
return asyncData.refresh()
|
await asyncData.refresh()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (instance) {
|
if (instance) {
|
||||||
|
@ -35,8 +35,6 @@ export function useCookie<T = string | null | undefined> (name: string, _opts?:
|
|||||||
|
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(`nuxt:cookies:${name}`)
|
const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(`nuxt:cookies:${name}`)
|
||||||
if (getCurrentScope()) { onScopeDispose(() => { channel?.close() }) }
|
|
||||||
|
|
||||||
const callback = () => {
|
const callback = () => {
|
||||||
writeClientCookie(name, cookie.value, opts as CookieSerializeOptions)
|
writeClientCookie(name, cookie.value, opts as CookieSerializeOptions)
|
||||||
channel?.postMessage(opts.encode(cookie.value as T))
|
channel?.postMessage(opts.encode(cookie.value as T))
|
||||||
@ -44,6 +42,14 @@ export function useCookie<T = string | null | undefined> (name: string, _opts?:
|
|||||||
|
|
||||||
let watchPaused = false
|
let watchPaused = false
|
||||||
|
|
||||||
|
if (getCurrentScope()) {
|
||||||
|
onScopeDispose(() => {
|
||||||
|
watchPaused = true
|
||||||
|
callback()
|
||||||
|
channel?.close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (channel) {
|
if (channel) {
|
||||||
channel.onmessage = (event) => {
|
channel.onmessage = (event) => {
|
||||||
watchPaused = true
|
watchPaused = true
|
||||||
|
@ -13,7 +13,7 @@ type AvailableRouterMethod<R extends NitroFetchRequest> = _AvailableRouterMethod
|
|||||||
export type FetchResult<ReqT extends NitroFetchRequest, M extends AvailableRouterMethod<ReqT>> = TypedInternalResponse<ReqT, unknown, Lowercase<M>>
|
export type FetchResult<ReqT extends NitroFetchRequest, M extends AvailableRouterMethod<ReqT>> = TypedInternalResponse<ReqT, unknown, Lowercase<M>>
|
||||||
|
|
||||||
type ComputedOptions<T extends Record<string, any>> = {
|
type ComputedOptions<T extends Record<string, any>> = {
|
||||||
[K in keyof T]: T[K] extends Function ? T[K] : T[K] extends Record<string, any> ? ComputedOptions<T[K]> | Ref<T[K]> | T[K] : Ref<T[K]> | T[K]
|
[K in keyof T]: T[K] extends Function ? T[K] : ComputedOptions<T[K]> | Ref<T[K]> | T[K]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NitroFetchOptions<R extends NitroFetchRequest, M extends AvailableRouterMethod<R> = AvailableRouterMethod<R>> extends FetchOptions {
|
interface NitroFetchOptions<R extends NitroFetchRequest, M extends AvailableRouterMethod<R> = AvailableRouterMethod<R>> extends FetchOptions {
|
||||||
@ -107,6 +107,8 @@ export function useFetch<
|
|||||||
pick,
|
pick,
|
||||||
watch,
|
watch,
|
||||||
immediate,
|
immediate,
|
||||||
|
getCachedData,
|
||||||
|
deep,
|
||||||
...fetchOptions
|
...fetchOptions
|
||||||
} = opts
|
} = opts
|
||||||
|
|
||||||
@ -122,6 +124,8 @@ export function useFetch<
|
|||||||
transform,
|
transform,
|
||||||
pick,
|
pick,
|
||||||
immediate,
|
immediate,
|
||||||
|
getCachedData,
|
||||||
|
deep,
|
||||||
watch: watch === false ? [] : [_fetchOptions, _request, ...(watch || [])]
|
watch: watch === false ? [] : [_fetchOptions, _request, ...(watch || [])]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -34,7 +34,7 @@
|
|||||||
"fs-extra": "^11.1.1",
|
"fs-extra": "^11.1.1",
|
||||||
"h3": "^1.8.2",
|
"h3": "^1.8.2",
|
||||||
"hash-sum": "^2.0.0",
|
"hash-sum": "^2.0.0",
|
||||||
"lodash-es": "^4.17.21",
|
"klona": "^2.0.6",
|
||||||
"magic-string": "^0.30.4",
|
"magic-string": "^0.30.4",
|
||||||
"memfs": "^4.6.0",
|
"memfs": "^4.6.0",
|
||||||
"mini-css-extract-plugin": "^2.7.6",
|
"mini-css-extract-plugin": "^2.7.6",
|
||||||
@ -65,7 +65,6 @@
|
|||||||
"@nuxt/schema": "workspace:*",
|
"@nuxt/schema": "workspace:*",
|
||||||
"@types/fs-extra": "11.0.2",
|
"@types/fs-extra": "11.0.2",
|
||||||
"@types/hash-sum": "1.0.0",
|
"@types/hash-sum": "1.0.0",
|
||||||
"@types/lodash-es": "4.17.9",
|
|
||||||
"@types/pify": "5.0.2",
|
"@types/pify": "5.0.2",
|
||||||
"@types/webpack-bundle-analyzer": "4.6.1",
|
"@types/webpack-bundle-analyzer": "4.6.1",
|
||||||
"@types/webpack-hot-middleware": "2.25.7",
|
"@types/webpack-hot-middleware": "2.25.7",
|
||||||
|
@ -6,7 +6,6 @@
|
|||||||
import { normalizeWebpackManifest } from 'vue-bundle-renderer'
|
import { normalizeWebpackManifest } from 'vue-bundle-renderer'
|
||||||
import { dirname } from 'pathe'
|
import { dirname } from 'pathe'
|
||||||
import hash from 'hash-sum'
|
import hash from 'hash-sum'
|
||||||
import { uniq } from 'lodash-es'
|
|
||||||
import fse from 'fs-extra'
|
import fse from 'fs-extra'
|
||||||
|
|
||||||
import type { Nuxt } from '@nuxt/schema'
|
import type { Nuxt } from '@nuxt/schema'
|
||||||
@ -18,6 +17,10 @@ interface PluginOptions {
|
|||||||
nuxt: Nuxt
|
nuxt: Nuxt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uniq <T> (items: T[]) {
|
||||||
|
return [...new Set(items)]
|
||||||
|
}
|
||||||
|
|
||||||
export default class VueSSRClientPlugin {
|
export default class VueSSRClientPlugin {
|
||||||
options: PluginOptions
|
options: PluginOptions
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { cloneDeep } from 'lodash-es'
|
|
||||||
import type { Configuration } from 'webpack'
|
import type { Configuration } from 'webpack'
|
||||||
import type { Nuxt, NuxtOptions } from '@nuxt/schema'
|
import type { Nuxt, NuxtOptions } from '@nuxt/schema'
|
||||||
import { logger } from '@nuxt/kit'
|
import { logger } from '@nuxt/kit'
|
||||||
|
import { klona } from 'klona'
|
||||||
|
|
||||||
export interface WebpackConfigContext {
|
export interface WebpackConfigContext {
|
||||||
nuxt: Nuxt
|
nuxt: Nuxt
|
||||||
@ -67,6 +67,6 @@ export function fileName (ctx: WebpackConfigContext, key: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getWebpackConfig (ctx: WebpackConfigContext): Configuration {
|
export function getWebpackConfig (ctx: WebpackConfigContext): Configuration {
|
||||||
// Clone deep avoid leaking config between Client and Server
|
// Clone to avoid leaking config between Client and Server
|
||||||
return cloneDeep(ctx.config)
|
return klona(ctx.config)
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { createCommonJS } from 'mlly'
|
import { createCommonJS } from 'mlly'
|
||||||
import { cloneDeep, defaults, merge } from 'lodash-es'
|
|
||||||
import { requireModule } from '@nuxt/kit'
|
import { requireModule } from '@nuxt/kit'
|
||||||
import type { Nuxt } from '@nuxt/schema'
|
import type { Nuxt } from '@nuxt/schema'
|
||||||
|
import { defu } from 'defu'
|
||||||
|
|
||||||
const isPureObject = (obj: unknown): obj is Object => obj !== null && !Array.isArray(obj) && typeof obj === 'object'
|
const isPureObject = (obj: unknown): obj is Object => obj !== null && !Array.isArray(obj) && typeof obj === 'object'
|
||||||
|
|
||||||
@ -26,15 +26,6 @@ const orderPresets = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getPostcssConfig = (nuxt: Nuxt) => {
|
export const getPostcssConfig = (nuxt: Nuxt) => {
|
||||||
function defaultConfig () {
|
|
||||||
return {
|
|
||||||
sourceMap: nuxt.options.webpack.cssSourceMap,
|
|
||||||
plugins: nuxt.options.postcss.plugins,
|
|
||||||
// Array, String or Function
|
|
||||||
order: 'autoprefixerAndCssnanoLast'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortPlugins ({ plugins, order }: any) {
|
function sortPlugins ({ plugins, order }: any) {
|
||||||
const names = Object.keys(plugins)
|
const names = Object.keys(plugins)
|
||||||
if (typeof order === 'string') {
|
if (typeof order === 'string') {
|
||||||
@ -43,32 +34,26 @@ export const getPostcssConfig = (nuxt: Nuxt) => {
|
|||||||
return typeof order === 'function' ? order(names, orderPresets) : (order || names)
|
return typeof order === 'function' ? order(names, orderPresets) : (order || names)
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadPlugins (config: any) {
|
|
||||||
if (!isPureObject(config.plugins)) { return }
|
|
||||||
|
|
||||||
// Map postcss plugins into instances on object mode once
|
|
||||||
const cjs = createCommonJS(import.meta.url)
|
|
||||||
config.plugins = sortPlugins(config).map((pluginName: string) => {
|
|
||||||
const pluginFn = requireModule(pluginName, { paths: [cjs.__dirname] })
|
|
||||||
const pluginOptions = config.plugins[pluginName]
|
|
||||||
if (!pluginOptions || typeof pluginFn !== 'function') { return null }
|
|
||||||
return pluginFn(pluginOptions)
|
|
||||||
}).filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nuxt.options.webpack.postcss || !nuxt.options.postcss) {
|
if (!nuxt.options.webpack.postcss || !nuxt.options.postcss) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
let postcssOptions = cloneDeep(nuxt.options.postcss)
|
const postcssOptions = defu({}, nuxt.options.postcss, {
|
||||||
// Apply default plugins
|
sourceMap: nuxt.options.webpack.cssSourceMap,
|
||||||
if (isPureObject(postcssOptions)) {
|
// Array, String or Function
|
||||||
if (Array.isArray(postcssOptions.plugins)) {
|
order: 'autoprefixerAndCssnanoLast'
|
||||||
defaults(postcssOptions, defaultConfig())
|
})
|
||||||
} else {
|
|
||||||
// Keep the order of default plugins
|
// Keep the order of default plugins
|
||||||
postcssOptions = merge({}, defaultConfig(), postcssOptions)
|
if (!Array.isArray(postcssOptions.plugins) && isPureObject(postcssOptions.plugins)) {
|
||||||
loadPlugins(postcssOptions)
|
// Map postcss plugins into instances on object mode once
|
||||||
|
const cjs = createCommonJS(import.meta.url)
|
||||||
|
postcssOptions.plugins = sortPlugins(postcssOptions).map((pluginName: string) => {
|
||||||
|
const pluginFn = requireModule(pluginName, { paths: [cjs.__dirname] })
|
||||||
|
const pluginOptions = postcssOptions.plugins[pluginName]
|
||||||
|
if (!pluginOptions || typeof pluginFn !== 'function') { return null }
|
||||||
|
return pluginFn(pluginOptions)
|
||||||
|
}).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -77,4 +62,3 @@ export const getPostcssConfig = (nuxt: Nuxt) => {
|
|||||||
postcssOptions
|
postcssOptions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@ -744,9 +744,9 @@ importers:
|
|||||||
hash-sum:
|
hash-sum:
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
lodash-es:
|
klona:
|
||||||
specifier: ^4.17.21
|
specifier: ^2.0.6
|
||||||
version: 4.17.21
|
version: 2.0.6
|
||||||
magic-string:
|
magic-string:
|
||||||
specifier: ^0.30.4
|
specifier: ^0.30.4
|
||||||
version: 0.30.4
|
version: 0.30.4
|
||||||
@ -832,9 +832,6 @@ importers:
|
|||||||
'@types/hash-sum':
|
'@types/hash-sum':
|
||||||
specifier: 1.0.0
|
specifier: 1.0.0
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
'@types/lodash-es':
|
|
||||||
specifier: 4.17.9
|
|
||||||
version: 4.17.9
|
|
||||||
'@types/pify':
|
'@types/pify':
|
||||||
specifier: 5.0.2
|
specifier: 5.0.2
|
||||||
version: 5.0.2
|
version: 5.0.2
|
||||||
@ -1272,6 +1269,7 @@ packages:
|
|||||||
/@babel/highlight@7.22.20:
|
/@babel/highlight@7.22.20:
|
||||||
resolution: {integrity: sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==}
|
resolution: {integrity: sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
requiresBuild: true
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-validator-identifier': 7.22.20
|
'@babel/helper-validator-identifier': 7.22.20
|
||||||
chalk: 2.4.2
|
chalk: 2.4.2
|
||||||
@ -7994,6 +7992,7 @@ packages:
|
|||||||
|
|
||||||
/lodash-es@4.17.21:
|
/lodash-es@4.17.21:
|
||||||
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
|
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/lodash._reinterpolate@3.0.0:
|
/lodash._reinterpolate@3.0.0:
|
||||||
resolution: {integrity: sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA==}
|
resolution: {integrity: sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA==}
|
||||||
|
@ -271,6 +271,24 @@ describe('pages', () => {
|
|||||||
await expectNoClientErrors('/another-parent')
|
await expectNoClientErrors('/another-parent')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('/client-server', async () => {
|
||||||
|
// expect no hydration issues
|
||||||
|
await expectNoClientErrors('/client-server')
|
||||||
|
const page = await createPage('/client-server')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
const bodyHTML = await page.innerHTML('body')
|
||||||
|
expect(await page.locator('.placeholder-to-ensure-no-override').all()).toHaveLength(5)
|
||||||
|
expect(await page.locator('.server').all()).toHaveLength(0)
|
||||||
|
expect(await page.locator('.client-fragment-server.client').all()).toHaveLength(2)
|
||||||
|
expect(await page.locator('.client-fragment-server-fragment.client').all()).toHaveLength(2)
|
||||||
|
expect(await page.locator('.client-server.client').all()).toHaveLength(1)
|
||||||
|
expect(await page.locator('.client-server-fragment.client').all()).toHaveLength(1)
|
||||||
|
expect(await page.locator('.client-server-fragment.client').all()).toHaveLength(1)
|
||||||
|
|
||||||
|
expect(bodyHTML).not.toContain('hello')
|
||||||
|
expect(bodyHTML).toContain('world')
|
||||||
|
})
|
||||||
|
|
||||||
it('/client-only-components', async () => {
|
it('/client-only-components', async () => {
|
||||||
const html = await $fetch('/client-only-components')
|
const html = await $fetch('/client-only-components')
|
||||||
// ensure fallbacks with classes and arbitrary attributes are rendered
|
// ensure fallbacks with classes and arbitrary attributes are rendered
|
||||||
@ -1907,6 +1925,12 @@ describe.skipIf(process.env.TEST_CONTEXT !== 'async')('Async context', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe.skipIf(isWindows)('useAsyncData', () => {
|
describe.skipIf(isWindows)('useAsyncData', () => {
|
||||||
|
it('works after useNuxtData call', async () => {
|
||||||
|
const page = await createPage('/useAsyncData/nuxt-data')
|
||||||
|
expect(await page.locator('body').getByText('resolved:true').textContent()).toContain('resolved:true')
|
||||||
|
await page.close()
|
||||||
|
})
|
||||||
|
|
||||||
it('single request resolves', async () => {
|
it('single request resolves', async () => {
|
||||||
await expectNoClientErrors('/useAsyncData/single')
|
await expectNoClientErrors('/useAsyncData/single')
|
||||||
})
|
})
|
||||||
|
11
test/fixtures/basic-types/types.ts
vendored
11
test/fixtures/basic-types/types.ts
vendored
@ -406,6 +406,17 @@ describe('composables', () => {
|
|||||||
expectTypeOf(test).toEqualTypeOf<string | undefined>()
|
expectTypeOf(test).toEqualTypeOf<string | undefined>()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('allows passing reactive values in useFetch', () => {
|
||||||
|
useFetch('/api/hey', {
|
||||||
|
headers: {
|
||||||
|
key: ref('test')
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
param: computed(() => 'thing')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('correctly types returns with key signatures', () => {
|
it('correctly types returns with key signatures', () => {
|
||||||
interface TestType {
|
interface TestType {
|
||||||
id: string
|
id: string
|
||||||
|
8
test/fixtures/basic/components/client/FragmentServer.client.vue
vendored
Normal file
8
test/fixtures/basic/components/client/FragmentServer.client.vue
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<div class="client-fragment-server client">
|
||||||
|
world
|
||||||
|
</div>
|
||||||
|
<div class="client-fragment-server client">
|
||||||
|
world
|
||||||
|
</div>
|
||||||
|
</template>
|
5
test/fixtures/basic/components/client/FragmentServer.server.vue
vendored
Normal file
5
test/fixtures/basic/components/client/FragmentServer.server.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="client-fragment-server server">
|
||||||
|
hello
|
||||||
|
</div>
|
||||||
|
</template>
|
8
test/fixtures/basic/components/client/FragmentServerFragment.client.vue
vendored
Normal file
8
test/fixtures/basic/components/client/FragmentServerFragment.client.vue
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<div class="client-fragment-server-fragment client">
|
||||||
|
world
|
||||||
|
</div>
|
||||||
|
<div class="client-fragment-server-fragment client">
|
||||||
|
world
|
||||||
|
</div>
|
||||||
|
</template>
|
8
test/fixtures/basic/components/client/FragmentServerFragment.server.vue
vendored
Normal file
8
test/fixtures/basic/components/client/FragmentServerFragment.server.vue
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<div class="client-fragment-server-fragment server">
|
||||||
|
hello
|
||||||
|
</div>
|
||||||
|
<div class="client-fragment-server-fragment server">
|
||||||
|
hello
|
||||||
|
</div>
|
||||||
|
</template>
|
5
test/fixtures/basic/components/client/Server.client.vue
vendored
Normal file
5
test/fixtures/basic/components/client/Server.client.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="client-server client">
|
||||||
|
world !
|
||||||
|
</div>
|
||||||
|
</template>
|
5
test/fixtures/basic/components/client/Server.server.vue
vendored
Normal file
5
test/fixtures/basic/components/client/Server.server.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="client-server server">
|
||||||
|
hello
|
||||||
|
</div>
|
||||||
|
</template>
|
5
test/fixtures/basic/components/client/ServerFragment.client.vue
vendored
Normal file
5
test/fixtures/basic/components/client/ServerFragment.client.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="client-server-fragment client">
|
||||||
|
world
|
||||||
|
</div>
|
||||||
|
</template>
|
8
test/fixtures/basic/components/client/ServerFragment.server.vue
vendored
Normal file
8
test/fixtures/basic/components/client/ServerFragment.server.vue
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<div class="client-server-fragment server">
|
||||||
|
hello
|
||||||
|
</div>
|
||||||
|
<div class="client-server-fragment server">
|
||||||
|
hello
|
||||||
|
</div>
|
||||||
|
</template>
|
28
test/fixtures/basic/pages/client-server.vue
vendored
Normal file
28
test/fixtures/basic/pages/client-server.vue
vendored
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="placeholder-to-ensure-no-override">
|
||||||
|
this should not be removed by hydration
|
||||||
|
</div>
|
||||||
|
<ClientFragmentServer />
|
||||||
|
|
||||||
|
<div class="placeholder-to-ensure-no-override">
|
||||||
|
this should not be removed by hydration
|
||||||
|
</div>
|
||||||
|
<ClientServerFragment />
|
||||||
|
|
||||||
|
<div class="placeholder-to-ensure-no-override">
|
||||||
|
this should not be removed by hydration
|
||||||
|
</div>
|
||||||
|
<ClientServer />
|
||||||
|
|
||||||
|
<div class="placeholder-to-ensure-no-override">
|
||||||
|
this should not be removed by hydration
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ClientFragmentServerFragment />
|
||||||
|
|
||||||
|
<div class="placeholder-to-ensure-no-override">
|
||||||
|
this should not be removed by hydration
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
15
test/fixtures/basic/pages/useAsyncData/nuxt-data.vue
vendored
Normal file
15
test/fixtures/basic/pages/useAsyncData/nuxt-data.vue
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="!pending"
|
||||||
|
v-text="'resolved:' + data.resolved"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
v-text="'loading'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
useNuxtData('call')
|
||||||
|
const { data, pending } = await useAsyncData('call', () => Promise.resolve({ resolved: true }), { server: false })
|
||||||
|
</script>
|
@ -143,6 +143,20 @@ describe('useAsyncData', () => {
|
|||||||
expect(useNuxtData('key').data.value).toBeUndefined()
|
expect(useNuxtData('key').data.value).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should be usable _after_ a useNuxtData call', async () => {
|
||||||
|
useNuxtApp().payload.data.call = null
|
||||||
|
const { data: cachedData } = useNuxtData('call')
|
||||||
|
expect(cachedData.value).toMatchInlineSnapshot('null')
|
||||||
|
const { data } = await useAsyncData('call', () => Promise.resolve({ resolved: true }), { server: false })
|
||||||
|
expect(cachedData.value).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"resolved": true,
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
expect(data.value).toEqual(cachedData.value)
|
||||||
|
clearNuxtData('call')
|
||||||
|
})
|
||||||
|
|
||||||
it('should be refreshable', async () => {
|
it('should be refreshable', async () => {
|
||||||
await useAsyncData('key', () => Promise.resolve('test'))
|
await useAsyncData('key', () => Promise.resolve('test'))
|
||||||
clearNuxtData('key')
|
clearNuxtData('key')
|
||||||
@ -151,6 +165,15 @@ describe('useAsyncData', () => {
|
|||||||
await refreshNuxtData('key')
|
await refreshNuxtData('key')
|
||||||
expect(data.data.value).toMatchInlineSnapshot('"test"')
|
expect(data.data.value).toMatchInlineSnapshot('"test"')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('allows custom access to a cache', async () => {
|
||||||
|
const { data } = await useAsyncData(() => ({ val: true }), { getCachedData: () => ({ val: false }) })
|
||||||
|
expect(data.value).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"val": false,
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('errors', () => {
|
describe('errors', () => {
|
||||||
|
Loading…
Reference in New Issue
Block a user