vike
Version:
The Framework *You* Control - Next.js & Nuxt alternative for unprecedented flexibility and dependability.
634 lines (633 loc) • 27.9 kB
JavaScript
export { renderPageClientSide };
export { getRenderCount };
export { disableClientRouting };
export { firstRenderStartPromise };
export { getPageContextClient };
import { assert, isSameErrorMessage, objectAssign, redirectHard, getGlobalObject, hasProp, augmentType, genPromise, isCallable, catchInfiniteLoop, } from './utils.js';
import { getPageContextFromClientHooks, getPageContextFromServerHooks, getPageContextFromHooks_isHydration, getPageContextFromHooks_serialized, setPageContextInitIsPassedToClient, } from './getPageContextFromHooks.js';
import { createPageContextClientSide } from './createPageContextClientSide.js';
import { addLinkPrefetchHandlers, addLinkPrefetchHandlers_unwatch, addLinkPrefetchHandlers_watch, getPageContextPrefetched, populatePageContextPrefetchCache, } from './prefetch.js';
import { assertInfo, assertWarning, isReact } from './utils.js';
import { execHookOnRenderClient } from '../shared/execHookOnRenderClient.js';
import { isErrorFetchingStaticAssets, loadPageConfigsLazyClientSide, } from '../shared/loadPageConfigsLazyClientSide.js';
import { pushHistoryState } from './history.js';
import { assertNoInfiniteAbortLoop, getPageContextFromAllRewrites, isAbortError, logAbortErrorHandled, } from '../../shared/route/abort.js';
import { route } from '../../shared/route/index.js';
import { isClientSideRoutable } from './isClientSideRoutable.js';
import { setScrollPosition } from './setScrollPosition.js';
import { scrollRestoration_initialRenderIsDone } from './scrollRestoration.js';
import { getErrorPageId } from '../../shared/error-page.js';
import { setPageContextCurrent } from './getPageContextCurrent.js';
import { getRouteStringParameterList } from '../../shared/route/resolveRouteString.js';
import { getCurrentUrl } from '../shared/getCurrentUrl.js';
import { execHookDirect, execHook } from '../../shared/hooks/execHook.js';
import { preparePageContextForPublicUsageClient, } from './preparePageContextForPublicUsageClient.js';
import { getHookFromPageContextNew } from '../../shared/hooks/getHook.js';
import { preparePageContextForPublicUsageClientMinimal } from '../shared/preparePageContextForPublicUsageClientShared.js';
const globalObject = getGlobalObject('runtime-client-routing/renderPageClientSide.ts', (() => {
const { promise: firstRenderStartPromise, resolve: firstRenderStartPromiseResolve } = genPromise();
return {
renderCounter: 0,
firstRenderStartPromise,
firstRenderStartPromiseResolve,
};
})());
const { firstRenderStartPromise } = globalObject;
async function renderPageClientSide(renderArgs) {
catchInfiniteLoop('renderPageClientSide()');
const { urlOriginal = getCurrentUrl(), overwriteLastHistoryEntry = false, isBackwardNavigation, pageContextsFromRewrite = [], redirectCount = 0, doNotRenderIfSamePage, isClientSideNavigation = true, pageContextInitClient, } = renderArgs;
let { scrollTarget } = renderArgs;
const { previousPageContext } = globalObject;
addLinkPrefetchHandlers_unwatch();
const { isRenderOutdated, setHydrationCanBeAborted, isFirstRender } = getIsRenderOutdated();
assertNoInfiniteAbortLoop(pageContextsFromRewrite.length, redirectCount);
const pageContextBeginArgs = {
urlOriginal,
isBackwardNavigation,
pageContextsFromRewrite,
isClientSideNavigation,
pageContextInitClient,
isFirstRender,
};
if (globalObject.clientRoutingIsDisabled) {
redirectHard(urlOriginal);
return;
}
globalObject.firstRenderStartPromiseResolve();
if (isRenderOutdated())
return;
await renderPageNominal();
return;
async function renderPageNominal() {
const onError = async (err) => {
await renderPageOnError({ err });
};
const pageContext = await getPageContextBegin(false, pageContextBeginArgs);
if (isRenderOutdated())
return;
// onPageTransitionStart()
if (globalObject.isFirstRenderDone) {
assert(previousPageContext);
// We use the hook of the previous page in order to be able to call onPageTransitionStart() before fetching the files of the next page.
// https://github.com/vikejs/vike/issues/1560
if (!globalObject.isTransitioning) {
globalObject.isTransitioning = true;
const hooks = getHookFromPageContextNew('onPageTransitionStart', previousPageContext);
try {
await execHookDirect(hooks, pageContext, preparePageContextForPublicUsageClientMinimal);
}
catch (err) {
await onError(err);
return;
}
if (isRenderOutdated())
return;
}
}
// Route
if (isFirstRender) {
const pageContextSerialized = getPageContextFromHooks_serialized();
// TO-DO/eventually: create helper assertPageContextFromHook()
assert(!('urlOriginal' in pageContextSerialized));
objectAssign(pageContext, pageContextSerialized);
// TODO/pageContext-prefetch: remove or change, because this only makes sense for a pre-rendered page
populatePageContextPrefetchCache(pageContext, { pageContextFromServerHooks: pageContextSerialized });
}
else {
let pageContextFromRoute;
try {
pageContextFromRoute = await route(pageContext);
}
catch (err) {
await onError(err);
return;
}
if (isRenderOutdated())
return;
if (!pageContextFromRoute.pageId) {
/*
// We don't use the client router to render the 404 page:
// - So that the +redirects setting (https://vike.dev/redirects) can be applied.
// - This is the main argument.
// - See also failed CI: https://github.com/vikejs/vike/pull/1871
// - So that server-side error tracking can track 404 links?
// - We do use the client router for rendering the error page, so I don't think this is much of an argument.
await renderErrorPage({ is404: true })
*/
redirectHard(urlOriginal);
return;
}
assert(hasProp(pageContextFromRoute, 'pageId', 'string')); // Help TS
const isClientRoutable = await isClientSideRoutable(pageContextFromRoute.pageId, pageContext);
if (isRenderOutdated())
return;
if (!isClientRoutable) {
redirectHard(urlOriginal);
return;
}
const isSamePage = pageContextFromRoute.pageId &&
previousPageContext?.pageId &&
pageContextFromRoute.pageId === previousPageContext.pageId;
if (doNotRenderIfSamePage && isSamePage) {
// Skip's Vike's rendering; let the user handle the navigation
return;
}
// TO-DO/eventually: create helper assertPageContextFromHook()
assert(!('urlOriginal' in pageContextFromRoute));
objectAssign(pageContext, pageContextFromRoute);
}
const res = await loadPageConfigsLazyClientSideAndExecHook(pageContext, isFirstRender, isRenderOutdated);
/* Already called inside loadPageConfigsLazyClientSideAndExecHook()
if (isRenderOutdated()) return
*/
if (res.skip)
return;
if ('err' in res) {
await onError(res.err);
return;
}
augmentType(pageContext, res.pageContext);
setPageContextCurrent(pageContext);
// Set global hydrationCanBeAborted
if (pageContext.exports.hydrationCanBeAborted) {
setHydrationCanBeAborted();
}
else {
assertWarning(!isReact(), 'You seem to be using React; we recommend setting hydrationCanBeAborted to true, see https://vike.dev/hydrationCanBeAborted', { onlyOnce: true });
}
// There wasn't any `await` but the isRenderOutdated() return value may have changed because we called setHydrationCanBeAborted()
if (isRenderOutdated())
return;
// Get pageContext from hooks (fetched from server, and/or directly called on the client-side)
if (isFirstRender) {
assert(hasProp(pageContext, '_hasPageContextFromServer', 'true'));
let pageContextAugmented;
try {
pageContextAugmented = await getPageContextFromHooks_isHydration(pageContext);
}
catch (err) {
await onError(err);
return;
}
if (isRenderOutdated())
return;
augmentType(pageContext, pageContextAugmented);
// Render page view
await renderPageView(pageContext);
}
else {
// Fetch pageContext from server-side hooks
let pageContextFromServerHooks;
const pageContextPrefetched = getPageContextPrefetched(pageContext);
if (pageContextPrefetched) {
pageContextFromServerHooks = pageContextPrefetched;
}
else {
try {
const result = await getPageContextFromServerHooks(pageContext, false);
if (result.is404ServerSideRouted)
return;
pageContextFromServerHooks = result.pageContextFromServerHooks;
// TODO/pageContext-prefetch: remove or change, because this only makes sense for a pre-rendered page
populatePageContextPrefetchCache(pageContext, result);
}
catch (err) {
await onError(err);
return;
}
}
if (isRenderOutdated())
return;
// TO-DO/eventually: create helper assertPageContextFromHook()
assert(!('urlOriginal' in pageContextFromServerHooks));
objectAssign(pageContext, pageContextFromServerHooks);
// Get pageContext from client-side hooks
let pageContextFromClientHooks;
try {
pageContextFromClientHooks = await getPageContextFromClientHooks(pageContext, false);
}
catch (err) {
await onError(err);
return;
}
if (isRenderOutdated())
return;
augmentType(pageContext, pageContextFromClientHooks);
await renderPageView(pageContext);
}
}
// When the normal page threw an error
// - Can be a URL rewrite upon `throw render('/some-url')`
// - Can be rendering the error page
// - Can be rendering Vike's generic error page (if no error page is defined, or if the error page throws an error)
async function renderPageOnError(args) {
const onError = (err) => {
if (!isSameErrorMessage(err, args.err)) {
/* When we can't render the error page, we prefer showing a blank page over letting the server-side try because otherwise:
- We risk running into an infinite loop of reloads which would overload the server.
- An infinite reloading page is a even worse UX than a blank page.
redirectHard(urlOriginal)
*/
console.error(err);
}
};
if ('err' in args) {
const { err } = args;
assert(err);
if (!isAbortError(err)) {
// We don't swallow 404 errors:
// - On the server-side, Vike swallows / doesn't show any 404 error log because it's expected that a user may go to some random non-existent URL. (We don't want to flood the app's error tracking with 404 logs.)
// - On the client-side, if the user navigates to a 404 then it means that the UI has a broken link. (It isn't expected that users can go to some random URL using the client-side router, as it would require, for example, the user to manually change the URL of a link by manually manipulating the DOM which highly unlikely.)
console.error(err);
}
else {
// We swallow throw redirect()/render() called by client-side hooks onBeforeRender()/data()/guard()
// We handle the abort error down below.
}
}
const pageContext = await getPageContextBegin(true, pageContextBeginArgs);
if (isRenderOutdated())
return;
objectAssign(pageContext, { routeParams: {} });
if (args.pageContextError)
objectAssign(pageContext, args.pageContextError);
if ('err' in args) {
const { err } = args;
assert(!('errorWhileRendering' in pageContext));
objectAssign(pageContext, { errorWhileRendering: err });
if (isAbortError(err)) {
const errAbort = err;
logAbortErrorHandled(err, !import.meta.env.DEV, pageContext);
const pageContextAbort = errAbort._pageContextAbort;
// throw render('/some-url')
if (pageContextAbort._urlRewrite) {
await renderPageClientSide({
...renderArgs,
scrollTarget: undefined,
pageContextsFromRewrite: [...pageContextsFromRewrite, pageContextAbort],
});
return;
}
// throw redirect('/some-url')
if (pageContextAbort._urlRedirect) {
const urlRedirect = pageContextAbort._urlRedirect.url;
if (!urlRedirect.startsWith('/')) {
// External redirection
redirectHard(urlRedirect);
return;
}
else {
await renderPageClientSide({
...renderArgs,
scrollTarget: undefined,
urlOriginal: urlRedirect,
overwriteLastHistoryEntry: false,
isBackwardNavigation: false,
redirectCount: redirectCount + 1,
});
}
return;
}
// throw render(statusCode)
assert(pageContextAbort.abortStatusCode);
assert(!('urlOriginal' in pageContextAbort));
objectAssign(pageContext, pageContextAbort);
if (pageContextAbort.abortStatusCode === 404) {
objectAssign(pageContext, { is404: true });
}
}
else {
objectAssign(pageContext, { is404: false });
}
}
const errorPageId = getErrorPageId(pageContext._pageFilesAll, pageContext._globalContext._pageConfigs);
if (!errorPageId)
throw new Error('No error page defined.');
objectAssign(pageContext, {
pageId: errorPageId,
});
const isClientRoutable = await isClientSideRoutable(pageContext.pageId, pageContext);
if (isRenderOutdated())
return;
if (!isClientRoutable) {
redirectHard(urlOriginal);
return;
}
const res = await loadPageConfigsLazyClientSideAndExecHook(pageContext, isFirstRender, isRenderOutdated);
/* Already called inside loadPageConfigsLazyClientSideAndExecHook()
if (isRenderOutdated()) return
*/
if (res.skip)
return;
if ('err' in res) {
onError(res.err);
return;
}
augmentType(pageContext, res.pageContext);
setPageContextCurrent(pageContext);
let pageContextFromServerHooks;
try {
const result = await getPageContextFromServerHooks(pageContext, true);
if (result.is404ServerSideRouted)
return;
pageContextFromServerHooks = result.pageContextFromServerHooks;
}
catch (err) {
onError(err);
return;
}
if (isRenderOutdated())
return;
// TO-DO/eventually: create helper assertPageContextFromHook()
assert(!('urlOriginal' in pageContextFromServerHooks));
objectAssign(pageContext, pageContextFromServerHooks);
let pageContextFromClientHooks;
try {
pageContextFromClientHooks = await getPageContextFromClientHooks(pageContext, true);
}
catch (err) {
onError(err);
return;
}
if (isRenderOutdated())
return;
augmentType(pageContext, pageContextFromClientHooks);
await renderPageView(pageContext, args);
}
async function renderPageView(pageContext, isErrorPage) {
const onError = async (err) => {
if (!isErrorPage) {
await renderPageOnError({ err });
}
else {
if (!isSameErrorMessage(err, isErrorPage.err)) {
console.error(err);
}
}
};
// We use globalObject.onRenderClientPreviousPromise in order to ensure that there is never two concurrent onRenderClient() calls
if (globalObject.onRenderClientPreviousPromise) {
// Make sure that the previous render has finished
await globalObject.onRenderClientPreviousPromise;
assert(globalObject.onRenderClientPreviousPromise === undefined);
if (isRenderOutdated())
return;
}
changeUrl(urlOriginal, overwriteLastHistoryEntry);
globalObject.previousPageContext = pageContext;
assert(globalObject.onRenderClientPreviousPromise === undefined);
const onRenderClientPromise = (async () => {
let onRenderClientError;
try {
await execHookOnRenderClient(pageContext, preparePageContextForPublicUsageClient);
}
catch (err) {
onRenderClientError = err;
}
globalObject.onRenderClientPreviousPromise = undefined;
globalObject.isFirstRenderDone = true;
return onRenderClientError;
})();
globalObject.onRenderClientPreviousPromise = onRenderClientPromise;
const onRenderClientError = await onRenderClientPromise;
assert(globalObject.onRenderClientPreviousPromise === undefined);
if (onRenderClientError) {
await onError(onRenderClientError);
if (!isErrorPage)
return;
}
/* We don't abort in order to ensure that onHydrationEnd() is called: we abort only after onHydrationEnd() is called.
if (isRenderOutdated(true)) return
*/
// onHydrationEnd()
if (isFirstRender && !onRenderClientError) {
try {
await execHook('onHydrationEnd', pageContext, preparePageContextForPublicUsageClient);
}
catch (err) {
await onError(err);
if (!isErrorPage)
return;
}
if (isRenderOutdated(true))
return;
}
// We purposely abort *after* onHydrationEnd() is called (see comment above).
if (isRenderOutdated(true))
return;
// onPageTransitionEnd()
if (globalObject.isTransitioning) {
globalObject.isTransitioning = undefined;
assert(previousPageContext);
const hooks = getHookFromPageContextNew('onPageTransitionEnd', previousPageContext);
try {
await execHookDirect(hooks, pageContext, preparePageContextForPublicUsageClient);
}
catch (err) {
await onError(err);
if (!isErrorPage)
return;
}
if (isRenderOutdated(true))
return;
}
if (!scrollTarget && previousPageContext) {
const keepScrollPositionPrev = getKeepScrollPositionSetting(previousPageContext);
const keepScrollPositionNext = getKeepScrollPositionSetting(pageContext);
if (keepScrollPositionNext !== false &&
keepScrollPositionPrev !== false &&
areKeysEqual(keepScrollPositionNext, keepScrollPositionPrev)) {
scrollTarget = { preserveScroll: true };
}
}
// Page scrolling
setScrollPosition(scrollTarget, urlOriginal);
scrollRestoration_initialRenderIsDone();
if (pageContext._hasPageContextFromServer)
setPageContextInitIsPassedToClient(pageContext);
// Add link prefetch handlers
addLinkPrefetchHandlers_watch();
addLinkPrefetchHandlers();
globalObject.renderedPageContext = pageContext;
stampFinished(urlOriginal);
}
}
async function getPageContextBegin(isForErrorPage, { urlOriginal, isBackwardNavigation, pageContextsFromRewrite, isClientSideNavigation, pageContextInitClient, isFirstRender, }) {
const previousPageContext = globalObject.previousPageContext ?? null;
const pageContext = await createPageContextClientSide(urlOriginal);
objectAssign(pageContext, {
isBackwardNavigation,
isClientSideNavigation,
isHydration: isFirstRender && !isForErrorPage,
previousPageContext,
...pageContextInitClient,
});
// TODO/next-major-release: remove
Object.defineProperty(pageContext, '_previousPageContext', {
get() {
assertWarning(false, 'pageContext._previousPageContext has been renamed pageContext.previousPageContext', {
showStackTrace: true,
onlyOnce: true,
});
return previousPageContext;
},
enumerable: false,
});
{
const pageContextFromAllRewrites = getPageContextFromAllRewrites(pageContextsFromRewrite);
assert(!('urlOriginal' in pageContextFromAllRewrites));
objectAssign(pageContext, pageContextFromAllRewrites);
}
return pageContext;
}
// For Vike tests (but also potentially for Vike users)
// https://github.com/vikejs/vike/blob/ffbc5cf16407bcc075f414447e50d997c87c0c94/test/playground/pages/nested-layout/e2e-test.ts#L59
function stampFinished(urlOriginal) {
window._vike ?? (window._vike = {});
window._vike.fullyRenderedUrl = urlOriginal;
}
function changeUrl(url, overwriteLastHistoryEntry) {
if (getCurrentUrl() === url)
return;
pushHistoryState(url, overwriteLastHistoryEntry);
}
function disableClientRouting(err, log) {
assert(isErrorFetchingStaticAssets(err));
globalObject.clientRoutingIsDisabled = true;
if (log) {
// We don't use console.error() to avoid flooding error trackers such as Sentry
console.log(err);
}
// @ts-ignore Since dist/cjs/client/ is never used, we can ignore this error.
const isProd = import.meta.env.PROD;
assertInfo(false, [
'Failed to fetch static asset.',
isProd ? 'This usually happens when a new frontend is deployed.' : null,
'Falling back to Server Routing.',
'(The next page navigation will use Server Routing instead of Client Routing.)',
]
.filter(Boolean)
.join(' '), { onlyOnce: true });
}
function getIsRenderOutdated() {
const renderNumber = ++globalObject.renderCounter;
assert(renderNumber >= 1);
let hydrationCanBeAborted = false;
const setHydrationCanBeAborted = () => {
hydrationCanBeAborted = true;
};
/** Whether the rendering should be aborted because a new rendering has started. We should call this after each `await`. */
const isRenderOutdated = (isRenderCleanup) => {
// Never abort first render if `hydrationCanBeAborted` isn't `true`
{
const isFirstRender = renderNumber === 1;
if (isFirstRender && !hydrationCanBeAborted && !isRenderCleanup) {
return false;
}
}
// If there is a newer rendering, we should abort all previous renderings
return renderNumber !== globalObject.renderCounter;
};
return {
isRenderOutdated,
setHydrationCanBeAborted,
isFirstRender: renderNumber === 1,
};
}
function getRenderCount() {
return globalObject.renderCounter;
}
function getKeepScrollPositionSetting(pageContext) {
const c = pageContext.from.configsStandard.keepScrollPosition;
if (!c)
return false;
let val = c.value;
const configDefinedAt = c.definedAt;
assert(configDefinedAt);
const routeParameterList = getRouteStringParameterList(configDefinedAt);
if (isCallable(val))
val = val(pageContext, {
configDefinedAt: c.definedAt,
/* We don't pass routeParameterList because it's useless: the user knows the parameter list.
routeParameterList
*/
});
if (val === true) {
return [
configDefinedAt,
...routeParameterList.map((param) => {
const val = pageContext.routeParams[param];
assert(val);
return val;
}),
];
}
// We skip validation and type-cast instead of assertUsage() in order to save client-side KBs
return val;
}
function areKeysEqual(key1, key2) {
if (key1 === key2)
return true;
if (!Array.isArray(key1) || !Array.isArray(key2))
return false;
return key1.length === key2.length && key1.every((_, i) => key1[i] === key2[i]);
}
/**
* Get the `pageContext` object on the client-side.
*
* https://vike.dev/getPageContextClient
*/
function getPageContextClient() {
return globalObject.renderedPageContext ?? null;
}
async function loadPageConfigsLazyClientSideAndExecHook(pageContext, isFirstRender, isRenderOutdated) {
let hasErr = false;
let err;
let pageContextAddendum;
try {
pageContextAddendum = await loadPageConfigsLazyClientSide(pageContext.pageId, pageContext._pageFilesAll, pageContext._globalContext._pageConfigs, pageContext._globalContext._pageConfigGlobal);
}
catch (err_) {
err = err_;
hasErr = true;
if (handleErrorFetchingStaticAssets(err, pageContext, isFirstRender)) {
return { skip: true };
}
else {
// Syntax error in user file
}
}
if (isRenderOutdated())
return { skip: true };
if (hasErr)
return { err };
objectAssign(pageContext, pageContextAddendum);
try {
await execHook('onCreatePageContext', pageContext, preparePageContextForPublicUsageClient);
}
catch (err_) {
err = err;
hasErr = true;
}
if (isRenderOutdated())
return { skip: true };
if (hasErr)
return { err };
return { pageContext };
}
function handleErrorFetchingStaticAssets(err, pageContext, isFirstRender) {
if (!isErrorFetchingStaticAssets(err)) {
return false;
}
if (isFirstRender) {
disableClientRouting(err, false);
// This may happen if the frontend was newly deployed during hydration.
// Ideally: re-try a couple of times by reloading the page (not entirely trivial to implement since `localStorage` is needed.)
throw err;
}
else {
disableClientRouting(err, true);
}
redirectHard(pageContext.urlOriginal);
return true;
}