328 lines
13 KiB
JavaScript
328 lines
13 KiB
JavaScript
import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, toRaw, watch, withMemo } from "vue";
|
|
import { debounce } from "perfect-debounce";
|
|
import { hash } from "ohash";
|
|
import { appendResponseHeader } from "h3";
|
|
import { randomUUID } from "uncrypto";
|
|
import { joinURL, withQuery } from "ufo";
|
|
import { useNuxtApp, useRuntimeConfig } from "../nuxt.js";
|
|
import { prerenderRoutes, useRequestEvent } from "../composables/ssr.js";
|
|
import { injectHead } from "../composables/head.js";
|
|
import { getFragmentHTML, isEndFragment, isStartFragment } from "./utils.js";
|
|
import { appBaseURL, remoteComponentIslands, selectiveClient } from "#build/nuxt.config.mjs";
|
|
const pKey = "_islandPromises";
|
|
const SSR_UID_RE = /data-island-uid="([^"]*)"/;
|
|
const DATA_ISLAND_UID_RE = /data-island-uid(="")?(?!="[^"])/g;
|
|
const SLOTNAME_RE = /data-island-slot="([^"]*)"/g;
|
|
const SLOT_FALLBACK_RE = / data-island-slot="([^"]*)"[^>]*>/g;
|
|
const ISLAND_SCOPE_ID_RE = /^<[^> ]*/;
|
|
let id = 1;
|
|
const getId = import.meta.client ? () => (id++).toString() : randomUUID;
|
|
const components = import.meta.client ? /* @__PURE__ */ new Map() : void 0;
|
|
async function loadComponents(source = appBaseURL, paths) {
|
|
if (!paths) {
|
|
return;
|
|
}
|
|
const promises = [];
|
|
for (const [component, item] of Object.entries(paths)) {
|
|
if (!components.has(component)) {
|
|
promises.push((async () => {
|
|
const chunkSource = joinURL(source, item.chunk);
|
|
const c = await import(
|
|
/* @vite-ignore */
|
|
chunkSource
|
|
).then((m) => m.default || m);
|
|
components.set(component, c);
|
|
})());
|
|
}
|
|
}
|
|
await Promise.all(promises);
|
|
}
|
|
export default defineComponent({
|
|
name: "NuxtIsland",
|
|
inheritAttrs: false,
|
|
props: {
|
|
name: {
|
|
type: String,
|
|
required: true
|
|
},
|
|
lazy: Boolean,
|
|
props: {
|
|
type: Object,
|
|
default: () => void 0
|
|
},
|
|
context: {
|
|
type: Object,
|
|
default: () => ({})
|
|
},
|
|
scopeId: {
|
|
type: String,
|
|
default: () => void 0
|
|
},
|
|
source: {
|
|
type: String,
|
|
default: () => void 0
|
|
},
|
|
dangerouslyLoadClientComponents: {
|
|
type: Boolean,
|
|
default: false
|
|
}
|
|
},
|
|
emits: ["error"],
|
|
async setup(props, { slots, expose, emit }) {
|
|
let canTeleport = import.meta.server;
|
|
const teleportKey = shallowRef(0);
|
|
const key = shallowRef(0);
|
|
const canLoadClientComponent = computed(() => selectiveClient && (props.dangerouslyLoadClientComponents || !props.source));
|
|
const error = ref(null);
|
|
const config = useRuntimeConfig();
|
|
const nuxtApp = useNuxtApp();
|
|
const filteredProps = computed(() => props.props ? Object.fromEntries(Object.entries(props.props).filter(([key2]) => !key2.startsWith("data-v-"))) : {});
|
|
const hashId = computed(() => hash([props.name, filteredProps.value, props.context, props.source]).replace(/[-_]/g, ""));
|
|
const instance = getCurrentInstance();
|
|
const event = useRequestEvent();
|
|
let activeHead;
|
|
const eventFetch = import.meta.server ? event.fetch : import.meta.dev ? $fetch.raw : globalThis.fetch;
|
|
const mounted = shallowRef(false);
|
|
onMounted(() => {
|
|
mounted.value = true;
|
|
teleportKey.value++;
|
|
});
|
|
onBeforeUnmount(() => {
|
|
if (activeHead) {
|
|
activeHead.dispose();
|
|
}
|
|
});
|
|
function setPayload(key2, result) {
|
|
const toRevive = {};
|
|
if (result.props) {
|
|
toRevive.props = result.props;
|
|
}
|
|
if (result.slots) {
|
|
toRevive.slots = result.slots;
|
|
}
|
|
if (result.components) {
|
|
toRevive.components = result.components;
|
|
}
|
|
if (result.head) {
|
|
toRevive.head = result.head;
|
|
}
|
|
nuxtApp.payload.data[key2] = {
|
|
__nuxt_island: {
|
|
key: key2,
|
|
...import.meta.server && import.meta.prerender ? {} : { params: { ...props.context, props: props.props ? JSON.stringify(props.props) : void 0 } },
|
|
result: toRevive
|
|
},
|
|
...result
|
|
};
|
|
}
|
|
const payloads = {};
|
|
if (instance.vnode.el) {
|
|
const slots2 = toRaw(nuxtApp.payload.data[`${props.name}_${hashId.value}`])?.slots;
|
|
if (slots2) {
|
|
payloads.slots = slots2;
|
|
}
|
|
if (selectiveClient) {
|
|
const components2 = toRaw(nuxtApp.payload.data[`${props.name}_${hashId.value}`])?.components;
|
|
if (components2) {
|
|
payloads.components = components2;
|
|
}
|
|
}
|
|
}
|
|
const ssrHTML = ref("");
|
|
if (import.meta.client && instance.vnode?.el) {
|
|
if (import.meta.dev) {
|
|
let currentEl = instance.vnode.el;
|
|
let startEl = null;
|
|
let isFirstElement = true;
|
|
while (currentEl) {
|
|
if (isEndFragment(currentEl)) {
|
|
if (startEl !== currentEl.previousSibling) {
|
|
console.warn(`[\`Server components(and islands)\`] "${props.name}" must have a single root element. (HTML comments are considered elements as well.)`);
|
|
}
|
|
break;
|
|
} else if (!isStartFragment(currentEl) && isFirstElement) {
|
|
isFirstElement = false;
|
|
if (currentEl.nodeType === 1) {
|
|
startEl = currentEl;
|
|
}
|
|
}
|
|
currentEl = currentEl.nextSibling;
|
|
}
|
|
}
|
|
ssrHTML.value = getFragmentHTML(instance.vnode.el, true)?.join("") || "";
|
|
const key2 = `${props.name}_${hashId.value}`;
|
|
nuxtApp.payload.data[key2] ||= {};
|
|
nuxtApp.payload.data[key2].html = ssrHTML.value.replaceAll(new RegExp(`data-island-uid="${ssrHTML.value.match(SSR_UID_RE)?.[1] || ""}"`, "g"), `data-island-uid=""`);
|
|
}
|
|
const uid = ref(ssrHTML.value.match(SSR_UID_RE)?.[1] || getId());
|
|
const currentSlots = new Set(Object.keys(slots));
|
|
const availableSlots = computed(() => new Set([...ssrHTML.value.matchAll(SLOTNAME_RE)].map((m) => m[1])));
|
|
const html = computed(() => {
|
|
let html2 = ssrHTML.value;
|
|
if (props.scopeId) {
|
|
html2 = html2.replace(ISLAND_SCOPE_ID_RE, (full) => full + " " + props.scopeId);
|
|
}
|
|
if (import.meta.client && !canLoadClientComponent.value) {
|
|
for (const [key2, value] of Object.entries(payloads.components || {})) {
|
|
html2 = html2.replace(new RegExp(` data-island-uid="${uid.value}" data-island-component="${key2}"[^>]*>`), (full) => {
|
|
return full + value.html;
|
|
});
|
|
}
|
|
}
|
|
if (payloads.slots) {
|
|
return html2.replaceAll(SLOT_FALLBACK_RE, (full, slotName) => {
|
|
if (!currentSlots.has(slotName)) {
|
|
return full + (payloads.slots?.[slotName]?.fallback || "");
|
|
}
|
|
return full;
|
|
});
|
|
}
|
|
return html2;
|
|
});
|
|
const head = injectHead();
|
|
async function _fetchComponent(force = false) {
|
|
const key2 = `${props.name}_${hashId.value}`;
|
|
if (!force && nuxtApp.payload.data[key2]?.html) {
|
|
return nuxtApp.payload.data[key2];
|
|
}
|
|
const url = remoteComponentIslands && props.source ? joinURL(props.source, `/__nuxt_island/${key2}.json`) : `/__nuxt_island/${key2}.json`;
|
|
if (import.meta.server && import.meta.prerender) {
|
|
nuxtApp.runWithContext(() => prerenderRoutes(url));
|
|
}
|
|
const r = await eventFetch(withQuery(import.meta.dev && import.meta.client || props.source ? url : joinURL(config.app.baseURL ?? "", url), {
|
|
...props.context,
|
|
props: props.props ? JSON.stringify(props.props) : void 0
|
|
}));
|
|
try {
|
|
const result = import.meta.server || !import.meta.dev ? await r.json() : r._data;
|
|
if (import.meta.server && import.meta.prerender) {
|
|
const hints = r.headers.get("x-nitro-prerender");
|
|
if (hints) {
|
|
appendResponseHeader(event, "x-nitro-prerender", hints);
|
|
}
|
|
}
|
|
setPayload(key2, result);
|
|
return result;
|
|
} catch (e) {
|
|
if (r.status !== 200) {
|
|
throw new Error(e.toString(), { cause: r });
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
async function fetchComponent(force = false) {
|
|
nuxtApp[pKey] ||= {};
|
|
nuxtApp[pKey][uid.value] ||= _fetchComponent(force).finally(() => {
|
|
delete nuxtApp[pKey][uid.value];
|
|
});
|
|
try {
|
|
const res = await nuxtApp[pKey][uid.value];
|
|
ssrHTML.value = res.html.replaceAll(DATA_ISLAND_UID_RE, `data-island-uid="${uid.value}"`);
|
|
key.value++;
|
|
error.value = null;
|
|
payloads.slots = res.slots || {};
|
|
payloads.components = res.components || {};
|
|
if (selectiveClient && import.meta.client) {
|
|
if (canLoadClientComponent.value && res.components) {
|
|
await loadComponents(props.source, res.components);
|
|
}
|
|
}
|
|
if (res?.head) {
|
|
if (activeHead) {
|
|
activeHead.patch(res.head);
|
|
} else {
|
|
activeHead = head.push(res.head);
|
|
}
|
|
}
|
|
if (import.meta.client) {
|
|
nextTick(() => {
|
|
canTeleport = true;
|
|
teleportKey.value++;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
error.value = e;
|
|
emit("error", e);
|
|
}
|
|
}
|
|
expose({
|
|
refresh: () => fetchComponent(true)
|
|
});
|
|
if (import.meta.hot) {
|
|
import.meta.hot.on(`nuxt-server-component:${props.name}`, () => {
|
|
fetchComponent(true);
|
|
});
|
|
}
|
|
if (import.meta.client) {
|
|
watch(props, debounce(() => fetchComponent(), 100), { deep: true });
|
|
}
|
|
if (import.meta.client && !instance.vnode.el && props.lazy) {
|
|
fetchComponent();
|
|
} else if (import.meta.server || !instance.vnode.el || !nuxtApp.payload.serverRendered) {
|
|
await fetchComponent();
|
|
} else if (selectiveClient && canLoadClientComponent.value) {
|
|
await loadComponents(props.source, payloads.components);
|
|
}
|
|
return (_ctx, _cache) => {
|
|
if (!html.value || error.value) {
|
|
return [slots.fallback?.({ error: error.value }) ?? createVNode("div")];
|
|
}
|
|
return [
|
|
withMemo([key.value], () => {
|
|
return createVNode(Fragment, { key: key.value }, [h(createStaticVNode(html.value || "<div></div>", 1))]);
|
|
}, _cache, 0),
|
|
// should away be triggered ONE tick after re-rendering the static node
|
|
withMemo([teleportKey.value], () => {
|
|
const teleports = [];
|
|
const isKeyOdd = teleportKey.value === 0 || !!(teleportKey.value && !(teleportKey.value % 2));
|
|
if (uid.value && html.value && (import.meta.server || props.lazy ? canTeleport : mounted.value || instance.vnode?.el)) {
|
|
for (const slot in slots) {
|
|
if (availableSlots.value.has(slot)) {
|
|
teleports.push(
|
|
createVNode(
|
|
Teleport,
|
|
// use different selectors for even and odd teleportKey to force trigger the teleport
|
|
{ to: import.meta.client ? `${isKeyOdd ? "div" : ""}[data-island-uid="${uid.value}"][data-island-slot="${slot}"]` : `uid=${uid.value};slot=${slot}` },
|
|
{ default: () => (payloads.slots?.[slot]?.props?.length ? payloads.slots[slot].props : [{}]).map((data) => slots[slot]?.(data)) }
|
|
)
|
|
);
|
|
}
|
|
}
|
|
if (selectiveClient) {
|
|
if (import.meta.server) {
|
|
if (payloads.components) {
|
|
for (const [id2, info] of Object.entries(payloads.components)) {
|
|
const { html: html2, slots: slots2 } = info;
|
|
let replaced = html2.replaceAll("data-island-uid", `data-island-uid="${uid.value}"`);
|
|
for (const slot in slots2) {
|
|
replaced = replaced.replaceAll(`data-island-slot="${slot}">`, (full) => full + slots2[slot]);
|
|
}
|
|
teleports.push(createVNode(Teleport, { to: `uid=${uid.value};client=${id2}` }, {
|
|
default: () => [createStaticVNode(replaced, 1)]
|
|
}));
|
|
}
|
|
}
|
|
} else if (canLoadClientComponent.value && payloads.components) {
|
|
for (const [id2, info] of Object.entries(payloads.components)) {
|
|
const { props: props2, slots: slots2 } = info;
|
|
const component = components.get(id2);
|
|
const vnode = createVNode(Teleport, { to: `${isKeyOdd ? "div" : ""}[data-island-uid='${uid.value}'][data-island-component="${id2}"]` }, {
|
|
default: () => {
|
|
return [h(component, props2, Object.fromEntries(Object.entries(slots2 || {}).map(([k, v]) => [
|
|
k,
|
|
() => createStaticVNode(`<div style="display: contents" data-island-uid data-island-slot="${k}">${v}</div>`, 1)
|
|
])))];
|
|
}
|
|
});
|
|
teleports.push(vnode);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return h(Fragment, teleports);
|
|
}, _cache, 1)
|
|
];
|
|
};
|
|
}
|
|
});
|