mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 05:35:13 +00:00
perf(nuxt): use prerendered islands to serialise/revive payload (#21461)
This commit is contained in:
parent
3b0b924946
commit
19fc2828fb
@ -42,6 +42,7 @@ export default defineComponent({
|
|||||||
const event = useRequestEvent()
|
const event = useRequestEvent()
|
||||||
const mounted = ref(false)
|
const mounted = ref(false)
|
||||||
onMounted(() => { mounted.value = true })
|
onMounted(() => { mounted.value = true })
|
||||||
|
|
||||||
const ssrHTML = ref<string>(process.client ? getFragmentHTML(instance.vnode?.el ?? null).join('') ?? '<div></div>' : '<div></div>')
|
const ssrHTML = ref<string>(process.client ? getFragmentHTML(instance.vnode?.el ?? null).join('') ?? '<div></div>' : '<div></div>')
|
||||||
const uid = ref<string>(ssrHTML.value.match(SSR_UID_RE)?.[1] ?? randomUUID())
|
const uid = ref<string>(ssrHTML.value.match(SSR_UID_RE)?.[1] ?? randomUUID())
|
||||||
const availableSlots = computed(() => {
|
const availableSlots = computed(() => {
|
||||||
@ -67,19 +68,37 @@ export default defineComponent({
|
|||||||
return getSlotProps(ssrHTML.value)
|
return getSlotProps(ssrHTML.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
function _fetchComponent () {
|
async function _fetchComponent () {
|
||||||
const url = `/__nuxt_island/${props.name}:${hashId.value}`
|
const key = `${props.name}:${hashId.value}`
|
||||||
|
if (nuxtApp.payload.data[key]) { return nuxtApp.payload.data[key] }
|
||||||
|
|
||||||
|
const url = `/__nuxt_island/${key}`
|
||||||
if (process.server && process.env.prerender) {
|
if (process.server && process.env.prerender) {
|
||||||
// Hint to Nitro to prerender the island component
|
// Hint to Nitro to prerender the island component
|
||||||
appendResponseHeader(event, 'x-nitro-prerender', url)
|
appendResponseHeader(event, 'x-nitro-prerender', url)
|
||||||
}
|
}
|
||||||
// TODO: Validate response
|
// TODO: Validate response
|
||||||
return $fetch<NuxtIslandResponse>(url, {
|
const result = await $fetch<NuxtIslandResponse>(url, {
|
||||||
params: {
|
params: {
|
||||||
...props.context,
|
...props.context,
|
||||||
props: props.props ? JSON.stringify(props.props) : undefined
|
props: props.props ? JSON.stringify(props.props) : undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
nuxtApp.payload.data[key] = {
|
||||||
|
__nuxt_island: {
|
||||||
|
key,
|
||||||
|
...(process.server && process.env.prerender)
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
params: {
|
||||||
|
...props.context,
|
||||||
|
props: props.props ? JSON.stringify(props.props) : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...result
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
const key = ref(0)
|
const key = ref(0)
|
||||||
async function fetchComponent () {
|
async function fetchComponent () {
|
||||||
|
@ -1,16 +1,32 @@
|
|||||||
import { reactive, ref, shallowReactive, shallowRef } from 'vue'
|
import { reactive, ref, shallowReactive, shallowRef } from 'vue'
|
||||||
import { definePayloadReviver, getNuxtClientPayload } from '#app/composables/payload'
|
import { definePayloadReviver, getNuxtClientPayload } from '#app/composables/payload'
|
||||||
import { createError } from '#app/composables/error'
|
import { createError } from '#app/composables/error'
|
||||||
import { defineNuxtPlugin } from '#app/nuxt'
|
import { defineNuxtPlugin, useNuxtApp } from '#app/nuxt'
|
||||||
|
|
||||||
const revivers = {
|
// @ts-expect-error Virtual file.
|
||||||
NuxtError: (data: any) => createError(data),
|
import { componentIslands } from '#build/nuxt.config.mjs'
|
||||||
EmptyShallowRef: (data: any) => shallowRef(data === '_' ? undefined : data === '0n' ? BigInt(0) : JSON.parse(data)),
|
|
||||||
EmptyRef: (data: any) => ref(data === '_' ? undefined : data === '0n' ? BigInt(0) : JSON.parse(data)),
|
const revivers: Record<string, (data: any) => any> = {
|
||||||
ShallowRef: (data: any) => shallowRef(data),
|
NuxtError: data => createError(data),
|
||||||
ShallowReactive: (data: any) => shallowReactive(data),
|
EmptyShallowRef: data => shallowRef(data === '_' ? undefined : data === '0n' ? BigInt(0) : JSON.parse(data)),
|
||||||
Ref: (data: any) => ref(data),
|
EmptyRef: data => ref(data === '_' ? undefined : data === '0n' ? BigInt(0) : JSON.parse(data)),
|
||||||
Reactive: (data: any) => reactive(data)
|
ShallowRef: data => shallowRef(data),
|
||||||
|
ShallowReactive: data => shallowReactive(data),
|
||||||
|
Ref: data => ref(data),
|
||||||
|
Reactive: data => reactive(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (componentIslands) {
|
||||||
|
revivers.Island = ({ key, params }: any) => {
|
||||||
|
const nuxtApp = useNuxtApp()
|
||||||
|
if (!nuxtApp.isHydrating) {
|
||||||
|
nuxtApp.payload.data[key] = nuxtApp.payload.data[key] || $fetch(`/__nuxt_island/${key}`, params ? { params } : {}).then((r) => {
|
||||||
|
nuxtApp.payload.data[key] = r
|
||||||
|
return r
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineNuxtPlugin({
|
export default defineNuxtPlugin({
|
||||||
|
@ -2,16 +2,22 @@ import { isReactive, isRef, isShallow, toRaw } from 'vue'
|
|||||||
import { definePayloadReducer } from '#app/composables/payload'
|
import { definePayloadReducer } from '#app/composables/payload'
|
||||||
import { isNuxtError } from '#app/composables/error'
|
import { isNuxtError } from '#app/composables/error'
|
||||||
import { defineNuxtPlugin } from '#app/nuxt'
|
import { defineNuxtPlugin } from '#app/nuxt'
|
||||||
/* Defining a plugin that will be used by the Nuxt framework. */
|
|
||||||
|
|
||||||
const reducers = {
|
// @ts-expect-error Virtual file.
|
||||||
NuxtError: (data: any) => isNuxtError(data) && data.toJSON(),
|
import { componentIslands } from '#build/nuxt.config.mjs'
|
||||||
EmptyShallowRef: (data: any) => isRef(data) && isShallow(data) && !data.value && (typeof data.value === 'bigint' ? '0n' : (JSON.stringify(data.value) || '_')),
|
|
||||||
EmptyRef: (data: any) => isRef(data) && !data.value && (typeof data.value === 'bigint' ? '0n' : (JSON.stringify(data.value) || '_')),
|
const reducers: Record<string, (data: any) => any> = {
|
||||||
ShallowRef: (data: any) => isRef(data) && isShallow(data) && data.value,
|
NuxtError: data => isNuxtError(data) && data.toJSON(),
|
||||||
ShallowReactive: (data: any) => isReactive(data) && isShallow(data) && toRaw(data),
|
EmptyShallowRef: data => isRef(data) && isShallow(data) && !data.value && (typeof data.value === 'bigint' ? '0n' : (JSON.stringify(data.value) || '_')),
|
||||||
Ref: (data: any) => isRef(data) && data.value,
|
EmptyRef: data => isRef(data) && !data.value && (typeof data.value === 'bigint' ? '0n' : (JSON.stringify(data.value) || '_')),
|
||||||
Reactive: (data: any) => isReactive(data) && toRaw(data)
|
ShallowRef: data => isRef(data) && isShallow(data) && data.value,
|
||||||
|
ShallowReactive: data => isReactive(data) && isShallow(data) && toRaw(data),
|
||||||
|
Ref: data => isRef(data) && data.value,
|
||||||
|
Reactive: data => isReactive(data) && toRaw(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (componentIslands) {
|
||||||
|
reducers.Island = data => data && data?.__nuxt_island
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineNuxtPlugin({
|
export default defineNuxtPlugin({
|
||||||
|
@ -1,166 +1,15 @@
|
|||||||
import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, nextTick, onMounted, ref, watch } from 'vue'
|
import { defineComponent, h } from 'vue'
|
||||||
import { debounce } from 'perfect-debounce'
|
import NuxtIsland from '#app/components/nuxt-island'
|
||||||
import { hash } from 'ohash'
|
|
||||||
import { appendResponseHeader } from 'h3'
|
|
||||||
|
|
||||||
import { useHead } from '@unhead/vue'
|
|
||||||
import { randomUUID } from 'uncrypto'
|
|
||||||
import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer'
|
|
||||||
import { useNuxtApp } from '#app/nuxt'
|
|
||||||
import { useRequestEvent } from '#app/composables/ssr'
|
|
||||||
import { useAsyncData } from '#app/composables/asyncData'
|
|
||||||
import { getFragmentHTML, getSlotProps } from '#app/components/utils'
|
|
||||||
|
|
||||||
const pKey = '_islandPromises'
|
|
||||||
const UID_ATTR = /nuxt-ssr-component-uid(="([^"]*)")?/
|
|
||||||
const SLOTNAME_RE = /nuxt-ssr-slot-name="([^"]*)"/g
|
|
||||||
const SLOT_FALLBACK_RE = /<div nuxt-slot-fallback-start="([^"]*)"[^>]*><\/div>(((?!<div nuxt-slot-fallback-end[^>]*>)[\s\S])*)<div nuxt-slot-fallback-end[^>]*><\/div>/g
|
|
||||||
const SSR_UID_RE = /nuxt-ssr-component-uid="([^"]*)"/
|
|
||||||
|
|
||||||
let id = 0
|
|
||||||
const getId = process.client ? () => 's' + (id--) : randomUUID
|
|
||||||
|
|
||||||
export const createServerComponent = (name: string) => {
|
export const createServerComponent = (name: string) => {
|
||||||
return defineComponent({
|
return defineComponent({
|
||||||
name,
|
name,
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
setup (_props, { attrs, slots }) {
|
setup (_props, { attrs, slots }) {
|
||||||
return () => h(NuxtServerComponent, {
|
return () => h(NuxtIsland, {
|
||||||
name,
|
name,
|
||||||
props: attrs
|
props: attrs
|
||||||
}, slots)
|
}, slots)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const NuxtServerComponent = defineComponent({
|
|
||||||
name: 'NuxtServerComponent',
|
|
||||||
props: {
|
|
||||||
name: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
type: Object,
|
|
||||||
default: () => undefined
|
|
||||||
},
|
|
||||||
context: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async setup (props, { slots }) {
|
|
||||||
const instance = getCurrentInstance()!
|
|
||||||
const uid = ref(getFragmentHTML(instance.vnode?.el)[0]?.match(SSR_UID_RE)?.[1] ?? getId())
|
|
||||||
|
|
||||||
const nuxtApp = useNuxtApp()
|
|
||||||
const mounted = ref(false)
|
|
||||||
const key = ref(0)
|
|
||||||
onMounted(() => { mounted.value = true })
|
|
||||||
const hashId = computed(() => hash([props.name, props.props, props.context]))
|
|
||||||
|
|
||||||
const event = useRequestEvent()
|
|
||||||
|
|
||||||
function _fetchComponent () {
|
|
||||||
const url = `/__nuxt_island/${props.name}:${hashId.value}`
|
|
||||||
if (process.server && process.env.prerender) {
|
|
||||||
// Hint to Nitro to prerender the island component
|
|
||||||
appendResponseHeader(event, 'x-nitro-prerender', url)
|
|
||||||
}
|
|
||||||
// TODO: Validate response
|
|
||||||
return $fetch<NuxtIslandResponse>(url, {
|
|
||||||
params: {
|
|
||||||
...props.context,
|
|
||||||
props: props.props ? JSON.stringify(props.props) : undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = useAsyncData(
|
|
||||||
`${props.name}:${hashId.value}`,
|
|
||||||
async () => {
|
|
||||||
nuxtApp[pKey] = nuxtApp[pKey] || {}
|
|
||||||
if (!nuxtApp[pKey][hashId.value]) {
|
|
||||||
nuxtApp[pKey][hashId.value] = _fetchComponent().finally(() => {
|
|
||||||
delete nuxtApp[pKey]![hashId.value]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const res: NuxtIslandResponse = await nuxtApp[pKey][hashId.value]
|
|
||||||
return {
|
|
||||||
html: res.html,
|
|
||||||
head: {
|
|
||||||
link: res.head.link,
|
|
||||||
style: res.head.style
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
immediate: process.server || !nuxtApp.isHydrating,
|
|
||||||
default: () => ({
|
|
||||||
html: '',
|
|
||||||
head: {
|
|
||||||
link: [], style: []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
useHead(() => res.data.value!.head)
|
|
||||||
|
|
||||||
if (process.client) {
|
|
||||||
watch(props, debounce(async () => {
|
|
||||||
await res.execute()
|
|
||||||
key.value++
|
|
||||||
if (process.client) {
|
|
||||||
// must await next tick for Teleport to work correctly with static node re-rendering
|
|
||||||
await nextTick()
|
|
||||||
}
|
|
||||||
setUid()
|
|
||||||
}, 100))
|
|
||||||
}
|
|
||||||
|
|
||||||
const slotProps = computed(() => {
|
|
||||||
return getSlotProps(res.data.value!.html)
|
|
||||||
})
|
|
||||||
const availableSlots = computed(() => {
|
|
||||||
return [...res.data.value!.html.matchAll(SLOTNAME_RE)].map(m => m[1])
|
|
||||||
})
|
|
||||||
|
|
||||||
const html = computed(() => {
|
|
||||||
const currentSlots = Object.keys(slots)
|
|
||||||
return res.data.value!.html
|
|
||||||
.replace(UID_ATTR, () => `nuxt-ssr-component-uid="${getId()}"`)
|
|
||||||
.replace(SLOT_FALLBACK_RE, (full, slotName, content) => {
|
|
||||||
// remove fallback to insert slots
|
|
||||||
if (currentSlots.includes(slotName)) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
return content
|
|
||||||
})
|
|
||||||
})
|
|
||||||
function setUid () {
|
|
||||||
uid.value = html.value.match(SSR_UID_RE)?.[1] ?? getId() as string
|
|
||||||
}
|
|
||||||
|
|
||||||
await res
|
|
||||||
|
|
||||||
if (process.server || !nuxtApp.isHydrating) {
|
|
||||||
setUid()
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
const nodes = [createVNode(Fragment, {
|
|
||||||
key: key.value
|
|
||||||
}, [createStaticVNode(html.value, 1)])]
|
|
||||||
if (uid.value && (mounted.value || nuxtApp.isHydrating || process.server)) {
|
|
||||||
for (const slot in slots) {
|
|
||||||
if (availableSlots.value.includes(slot)) {
|
|
||||||
nodes.push(createVNode(Teleport, { to: process.client ? `[nuxt-ssr-component-uid='${uid.value}'] [nuxt-ssr-slot-name='${slot}']` : `uid=${uid.value};slot=${slot}` }, {
|
|
||||||
default: () => (slotProps.value[slot] ?? [undefined]).map((data: any) => slots[slot]?.(data))
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nodes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
@ -292,6 +292,7 @@ export const nuxtConfigTemplate = {
|
|||||||
return [
|
return [
|
||||||
...Object.entries(ctx.nuxt.options.app).map(([k, v]) => `export const ${camelCase('app-' + k)} = ${JSON.stringify(v)}`),
|
...Object.entries(ctx.nuxt.options.app).map(([k, v]) => `export const ${camelCase('app-' + k)} = ${JSON.stringify(v)}`),
|
||||||
`export const renderJsonPayloads = ${!!ctx.nuxt.options.experimental.renderJsonPayloads}`,
|
`export const renderJsonPayloads = ${!!ctx.nuxt.options.experimental.renderJsonPayloads}`,
|
||||||
|
`export const componentIslands = ${!!ctx.nuxt.options.experimental.componentIslands}`,
|
||||||
`export const devPagesDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.dir.pages) : 'null'}`,
|
`export const devPagesDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.dir.pages) : 'null'}`,
|
||||||
`export const devRootDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.rootDir) : 'null'}`
|
`export const devRootDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.rootDir) : 'null'}`
|
||||||
].join('\n\n')
|
].join('\n\n')
|
||||||
|
@ -168,6 +168,8 @@ describe('pages', () => {
|
|||||||
await page.waitForLoadState('networkidle')
|
await page.waitForLoadState('networkidle')
|
||||||
expect(await page.innerText('body')).toContain('Composable | foo: auto imported from ~/composables/foo.ts')
|
expect(await page.innerText('body')).toContain('Composable | foo: auto imported from ~/composables/foo.ts')
|
||||||
|
|
||||||
|
await page.close()
|
||||||
|
|
||||||
await expectNoClientErrors('/proxy')
|
await expectNoClientErrors('/proxy')
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -446,8 +448,6 @@ describe('pages', () => {
|
|||||||
|
|
||||||
// test islands mounted client side with slot
|
// test islands mounted client side with slot
|
||||||
await page.locator('#show-island').click()
|
await page.locator('#show-island').click()
|
||||||
await page.waitForResponse(response => response.url().includes('/__nuxt_island/') && response.status() === 200)
|
|
||||||
await page.waitForLoadState('networkidle')
|
|
||||||
expect(await page.locator('#island-mounted-client-side').innerHTML()).toContain('Interactive testing slot post SSR')
|
expect(await page.locator('#island-mounted-client-side').innerHTML()).toContain('Interactive testing slot post SSR')
|
||||||
|
|
||||||
await page.close()
|
await page.close()
|
||||||
@ -1202,10 +1202,36 @@ describe.skipIf(isDev() || isWebpack)('inlining component styles', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('prefetching', () => {
|
describe.skipIf(isDev() || isWindows || !isRenderingJson)('prefetching', () => {
|
||||||
it('should prefetch components', async () => {
|
it('should prefetch components', async () => {
|
||||||
await expectNoClientErrors('/prefetch/components')
|
await expectNoClientErrors('/prefetch/components')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should prefetch server components', async () => {
|
||||||
|
await expectNoClientErrors('/prefetch/server-components')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should prefetch everything needed when NuxtLink is used', async () => {
|
||||||
|
const page = await createPage()
|
||||||
|
const requests: string[] = []
|
||||||
|
|
||||||
|
page.on('request', (req) => {
|
||||||
|
requests.push(req.url().replace(url('/'), '/').replace(/\.[^.]+\./g, '.'))
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(url('/prefetch'))
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
const snapshot = [...requests]
|
||||||
|
await page.click('[href="/prefetch/server-components"]')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
expect(await page.innerHTML('#async-server-component-count')).toBe('34')
|
||||||
|
|
||||||
|
expect(requests).toEqual(snapshot)
|
||||||
|
await page.close()
|
||||||
|
})
|
||||||
|
|
||||||
it('should not prefetch certain dynamic imports by default', async () => {
|
it('should not prefetch certain dynamic imports by default', async () => {
|
||||||
const html = await $fetch('/auth')
|
const html = await $fetch('/auth')
|
||||||
// should not prefetch global components
|
// should not prefetch global components
|
||||||
@ -1596,6 +1622,13 @@ describe.skipIf(isDev() || isWindows || !isRenderingJson)('payload rendering', (
|
|||||||
|
|
||||||
await page.close()
|
await page.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.skipIf(!isRenderingJson)('should not include server-component HTML in payload', async () => {
|
||||||
|
const payload = await $fetch('/prefetch/server-components/_payload.json', { responseType: 'text' })
|
||||||
|
const entries = Object.entries(parsePayload(payload))
|
||||||
|
const [key, serialisedComponent] = entries.find(([key]) => key.startsWith('AsyncServerComponent')) || []
|
||||||
|
expect(serialisedComponent).toEqual(key)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe.skipIf(isWindows)('useAsyncData', () => {
|
describe.skipIf(isWindows)('useAsyncData', () => {
|
||||||
|
7
test/fixtures/basic/pages/prefetch/index.vue
vendored
Normal file
7
test/fixtures/basic/pages/prefetch/index.vue
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NuxtLink :to="{ name: 'prefetch-server-components'}">
|
||||||
|
Server components page
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
7
test/fixtures/basic/pages/prefetch/server-components.vue
vendored
Normal file
7
test/fixtures/basic/pages/prefetch/server-components.vue
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
Testing prefetching server components:
|
||||||
|
|
||||||
|
<AsyncServerComponent :count="34" />
|
||||||
|
</div>
|
||||||
|
</template>
|
@ -102,6 +102,7 @@ const revivers = {
|
|||||||
EmptyRef: (data: any) => ref(JSON.parse(data)),
|
EmptyRef: (data: any) => ref(JSON.parse(data)),
|
||||||
ShallowRef: (data: any) => shallowRef(data),
|
ShallowRef: (data: any) => shallowRef(data),
|
||||||
ShallowReactive: (data: any) => shallowReactive(data),
|
ShallowReactive: (data: any) => shallowReactive(data),
|
||||||
|
Island: (key: any) => key,
|
||||||
Ref: (data: any) => ref(data),
|
Ref: (data: any) => ref(data),
|
||||||
Reactive: (data: any) => reactive(data),
|
Reactive: (data: any) => reactive(data),
|
||||||
// test fixture reviver only
|
// test fixture reviver only
|
||||||
|
Loading…
Reference in New Issue
Block a user