summaryrefslogtreecommitdiff
path: root/packages/astro/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/astro/src')
-rw-r--r--packages/astro/src/@types/astro.ts17
-rw-r--r--packages/astro/src/cli/add/index.ts11
-rw-r--r--packages/astro/src/core/app/index.ts29
-rw-r--r--packages/astro/src/core/build/buildPipeline.ts20
-rw-r--r--packages/astro/src/core/build/generate.ts297
-rw-r--r--packages/astro/src/core/build/internal.ts26
-rw-r--r--packages/astro/src/core/build/page-data.ts66
-rw-r--r--packages/astro/src/core/build/static-build.ts20
-rw-r--r--packages/astro/src/core/build/types.ts3
-rw-r--r--packages/astro/src/core/endpoint/index.ts28
-rw-r--r--packages/astro/src/core/middleware/index.ts2
-rw-r--r--packages/astro/src/core/pipeline.ts10
-rw-r--r--packages/astro/src/core/render/context.ts24
-rw-r--r--packages/astro/src/core/render/core.ts2
-rw-r--r--packages/astro/src/core/render/result.ts27
-rw-r--r--packages/astro/src/core/routing/manifest/create.ts27
-rw-r--r--packages/astro/src/core/routing/manifest/serialization.ts6
-rw-r--r--packages/astro/src/core/routing/match.ts8
-rw-r--r--packages/astro/src/prefetch/index.ts34
-rw-r--r--packages/astro/src/runtime/server/transition.ts45
-rw-r--r--packages/astro/src/transitions/events.ts184
-rw-r--r--packages/astro/src/transitions/index.ts1
-rw-r--r--packages/astro/src/transitions/router.ts400
-rw-r--r--packages/astro/src/transitions/types.ts10
-rw-r--r--packages/astro/src/transitions/vite-plugin-transitions.ts9
-rw-r--r--packages/astro/src/virtual-modules/transitions-events.ts1
-rw-r--r--packages/astro/src/virtual-modules/transitions-types.ts1
-rw-r--r--packages/astro/src/vite-plugin-astro-server/route.ts8
28 files changed, 882 insertions, 434 deletions
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts
index cbaf568c7..fff91ca10 100644
--- a/packages/astro/src/@types/astro.ts
+++ b/packages/astro/src/@types/astro.ts
@@ -2112,6 +2112,11 @@ interface AstroSharedContext<
*/
preferredLocaleList: string[] | undefined;
+
+ /**
+ * The current locale computed from the URL of the request. It matches the locales in `i18n.locales`, and returns `undefined` otherwise.
+ */
+ currentLocale: string | undefined;
}
export interface APIContext<
@@ -2241,6 +2246,11 @@ export interface APIContext<
* [quality value]: https://developer.mozilla.org/en-US/docs/Glossary/Quality_values
*/
preferredLocaleList: string[] | undefined;
+
+ /**
+ * The current locale computed from the URL of the request. It matches the locales in `i18n.locales`, and returns `undefined` otherwise.
+ */
+ currentLocale: string | undefined;
}
export type EndpointOutput =
@@ -2424,16 +2434,21 @@ export interface RouteData {
prerender: boolean;
redirect?: RedirectConfig;
redirectRoute?: RouteData;
+ fallbackRoutes: RouteData[];
}
export type RedirectRouteData = RouteData & {
redirect: string;
};
-export type SerializedRouteData = Omit<RouteData, 'generate' | 'pattern' | 'redirectRoute'> & {
+export type SerializedRouteData = Omit<
+ RouteData,
+ 'generate' | 'pattern' | 'redirectRoute' | 'fallbackRoutes'
+> & {
generate: undefined;
pattern: string;
redirectRoute: SerializedRouteData | undefined;
+ fallbackRoutes: SerializedRouteData[];
_meta: {
trailingSlash: AstroConfig['trailingSlash'];
};
diff --git a/packages/astro/src/cli/add/index.ts b/packages/astro/src/cli/add/index.ts
index 42b160665..80c0e10ff 100644
--- a/packages/astro/src/cli/add/index.ts
+++ b/packages/astro/src/cli/add/index.ts
@@ -731,8 +731,11 @@ async function fetchPackageJson(
const res = await fetch(`${registry}/${packageName}/${tag}`);
if (res.status >= 200 && res.status < 300) {
return await res.json();
- } else {
+ } else if (res.status === 404) {
+ // 404 means the package doesn't exist, so we don't need an error message here
return new Error();
+ } else {
+ return new Error(`Failed to fetch ${registry}/${packageName}/${tag} - GET ${res.status}`);
}
}
@@ -754,6 +757,9 @@ export async function validateIntegrations(integrations: string[]): Promise<Inte
} else {
const firstPartyPkgCheck = await fetchPackageJson('@astrojs', name, tag);
if (firstPartyPkgCheck instanceof Error) {
+ if (firstPartyPkgCheck.message) {
+ spinner.warn(yellow(firstPartyPkgCheck.message));
+ }
spinner.warn(
yellow(`${bold(integration)} is not an official Astro package. Use at your own risk!`)
);
@@ -780,6 +786,9 @@ export async function validateIntegrations(integrations: string[]): Promise<Inte
if (pkgType === 'third-party') {
const thirdPartyPkgCheck = await fetchPackageJson(scope, name, tag);
if (thirdPartyPkgCheck instanceof Error) {
+ if (thirdPartyPkgCheck.message) {
+ spinner.warn(yellow(thirdPartyPkgCheck.message));
+ }
throw new Error(`Unable to fetch ${bold(integration)}. Does the package exist?`);
} else {
pkgJson = thirdPartyPkgCheck as any;
diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts
index 4c6fb5783..23ecba837 100644
--- a/packages/astro/src/core/app/index.ts
+++ b/packages/astro/src/core/app/index.ts
@@ -127,6 +127,13 @@ export class App {
}
return pathname;
}
+
+ #getPathnameFromRequest(request: Request): string {
+ const url = new URL(request.url);
+ const pathname = prependForwardSlash(this.removeBase(url.pathname));
+ return pathname;
+ }
+
match(request: Request, _opts: MatchOptions = {}): RouteData | undefined {
const url = new URL(request.url);
// ignore requests matching public assets
@@ -151,7 +158,8 @@ export class App {
}
Reflect.set(request, clientLocalsSymbol, locals ?? {});
- const defaultStatus = this.#getDefaultStatusCode(routeData.route);
+ const pathname = this.#getPathnameFromRequest(request);
+ const defaultStatus = this.#getDefaultStatusCode(routeData, pathname);
const mod = await this.#getModuleForRoute(routeData);
const pageModule = (await mod.page()) as any;
@@ -234,7 +242,9 @@ export class App {
status,
env: this.#pipeline.env,
mod: handler as any,
- locales: this.#manifest.i18n ? this.#manifest.i18n.locales : undefined,
+ locales: this.#manifest.i18n?.locales,
+ routingStrategy: this.#manifest.i18n?.routingStrategy,
+ defaultLocale: this.#manifest.i18n?.defaultLocale,
});
} else {
const pathname = prependForwardSlash(this.removeBase(url.pathname));
@@ -269,7 +279,9 @@ export class App {
status,
mod,
env: this.#pipeline.env,
- locales: this.#manifest.i18n ? this.#manifest.i18n.locales : undefined,
+ locales: this.#manifest.i18n?.locales,
+ routingStrategy: this.#manifest.i18n?.routingStrategy,
+ defaultLocale: this.#manifest.i18n?.defaultLocale,
});
}
}
@@ -365,8 +377,15 @@ export class App {
});
}
- #getDefaultStatusCode(route: string): number {
- route = removeTrailingForwardSlash(route);
+ #getDefaultStatusCode(routeData: RouteData, pathname: string): number {
+ if (!routeData.pattern.exec(pathname)) {
+ for (const fallbackRoute of routeData.fallbackRoutes) {
+ if (fallbackRoute.pattern.test(pathname)) {
+ return 302;
+ }
+ }
+ }
+ const route = removeTrailingForwardSlash(routeData.route);
if (route.endsWith('/404')) return 404;
if (route.endsWith('/500')) return 500;
return 200;
diff --git a/packages/astro/src/core/build/buildPipeline.ts b/packages/astro/src/core/build/buildPipeline.ts
index fc315ff7d..e9b3c683e 100644
--- a/packages/astro/src/core/build/buildPipeline.ts
+++ b/packages/astro/src/core/build/buildPipeline.ts
@@ -164,17 +164,15 @@ export class BuildPipeline extends Pipeline {
}
}
- for (const [path, pageDataList] of this.#internals.pagesByComponents.entries()) {
- for (const pageData of pageDataList) {
- if (routeIsRedirect(pageData.route)) {
- pages.set(pageData, path);
- } else if (
- routeIsFallback(pageData.route) &&
- (i18nHasFallback(this.getConfig()) ||
- (routeIsFallback(pageData.route) && pageData.route.route === '/'))
- ) {
- pages.set(pageData, path);
- }
+ for (const [path, pageData] of this.#internals.pagesByComponent.entries()) {
+ if (routeIsRedirect(pageData.route)) {
+ pages.set(pageData, path);
+ } else if (
+ routeIsFallback(pageData.route) &&
+ (i18nHasFallback(this.getConfig()) ||
+ (routeIsFallback(pageData.route) && pageData.route.route === '/'))
+ ) {
+ pages.set(pageData, path);
}
}
diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts
index 4f7c36e8e..3ffd13b7d 100644
--- a/packages/astro/src/core/build/generate.ts
+++ b/packages/astro/src/core/build/generate.ts
@@ -328,6 +328,9 @@ async function generatePage(
: magenta('λ');
if (isRelativePath(pageData.route.component)) {
logger.info(null, `${icon} ${pageData.route.route}`);
+ for (const fallbackRoute of pageData.route.fallbackRoutes) {
+ logger.info(null, `${icon} ${fallbackRoute.route}`);
+ }
} else {
logger.info(null, `${icon} ${pageData.route.component}`);
}
@@ -349,6 +352,13 @@ async function generatePage(
}
}
+function* eachRouteInRouteData(data: PageBuildData) {
+ yield data.route;
+ for (const fallbackRoute of data.route.fallbackRoutes) {
+ yield fallbackRoute;
+ }
+}
+
async function getPathsForRoute(
pageData: PageBuildData,
mod: ComponentInstance,
@@ -361,18 +371,24 @@ async function getPathsForRoute(
if (pageData.route.pathname) {
paths.push(pageData.route.pathname);
builtPaths.add(pageData.route.pathname);
+ for (const virtualRoute of pageData.route.fallbackRoutes) {
+ if (virtualRoute.pathname) {
+ paths.push(virtualRoute.pathname);
+ builtPaths.add(virtualRoute.pathname);
+ }
+ }
} else {
- const route = pageData.route;
- const staticPaths = await callGetStaticPaths({
- mod,
- route,
- routeCache: opts.routeCache,
- logger,
- ssr: isServerLikeOutput(opts.settings.config),
- }).catch((err) => {
- logger.debug('build', `├── ${bold(red('✗'))} ${route.component}`);
- throw err;
- });
+ for (const route of eachRouteInRouteData(pageData)) {
+ const staticPaths = await callGetStaticPaths({
+ mod,
+ route,
+ routeCache: opts.routeCache,
+ logger,
+ ssr: isServerLikeOutput(opts.settings.config),
+ }).catch((err) => {
+ logger.debug('build', `├── ${bold(red('✗'))} ${route.component}`);
+ throw err;
+ });
const label = staticPaths.length === 1 ? 'page' : 'pages';
logger.debug(
@@ -382,35 +398,38 @@ async function getPathsForRoute(
)}`
);
- paths = staticPaths
- .map((staticPath) => {
- try {
- return route.generate(staticPath.params);
- } catch (e) {
- if (e instanceof TypeError) {
- throw getInvalidRouteSegmentError(e, route, staticPath);
- }
- throw e;
- }
- })
- .filter((staticPath) => {
- // The path hasn't been built yet, include it
- if (!builtPaths.has(removeTrailingForwardSlash(staticPath))) {
- return true;
- }
-
- // The path was already built once. Check the manifest to see if
- // this route takes priority for the final URL.
- // NOTE: The same URL may match multiple routes in the manifest.
- // Routing priority needs to be verified here for any duplicate
- // paths to ensure routing priority rules are enforced in the final build.
- const matchedRoute = matchRoute(staticPath, opts.manifest);
- return matchedRoute === route;
- });
+ paths.push(
+ ...staticPaths
+ .map((staticPath) => {
+ try {
+ return route.generate(staticPath.params);
+ } catch (e) {
+ if (e instanceof TypeError) {
+ throw getInvalidRouteSegmentError(e, route, staticPath);
+ }
+ throw e;
+ }
+ })
+ .filter((staticPath) => {
+ // The path hasn't been built yet, include it
+ if (!builtPaths.has(removeTrailingForwardSlash(staticPath))) {
+ return true;
+ }
+
+ // The path was already built once. Check the manifest to see if
+ // this route takes priority for the final URL.
+ // NOTE: The same URL may match multiple routes in the manifest.
+ // Routing priority needs to be verified here for any duplicate
+ // paths to ensure routing priority rules are enforced in the final build.
+ const matchedRoute = matchRoute(staticPath, opts.manifest);
+ return matchedRoute === route;
+ })
+ );
- // Add each path to the builtPaths set, to avoid building it again later.
- for (const staticPath of paths) {
- builtPaths.add(removeTrailingForwardSlash(staticPath));
+ // Add each path to the builtPaths set, to avoid building it again later.
+ for (const staticPath of paths) {
+ builtPaths.add(removeTrailingForwardSlash(staticPath));
+ }
}
}
@@ -497,99 +516,102 @@ async function generatePath(pathname: string, gopts: GeneratePathOptions, pipeli
const manifest = pipeline.getManifest();
const { mod, scripts: hoistedScripts, styles: _styles, pageData } = gopts;
- // This adds the page name to the array so it can be shown as part of stats.
- if (pageData.route.type === 'page') {
- addPageName(pathname, pipeline.getStaticBuildOptions());
- }
-
- pipeline.getEnvironment().logger.debug('build', `Generating: ${pathname}`);
+ for (const route of eachRouteInRouteData(pageData)) {
+ // This adds the page name to the array so it can be shown as part of stats.
+ if (route.type === 'page') {
+ addPageName(pathname, pipeline.getStaticBuildOptions());
+ }
- // may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
- const links = new Set<never>();
- const scripts = createModuleScriptsSet(
- hoistedScripts ? [hoistedScripts] : [],
- manifest.base,
- manifest.assetsPrefix
- );
- const styles = createStylesheetElementSet(_styles, manifest.base, manifest.assetsPrefix);
+ pipeline.getEnvironment().logger.debug('build', `Generating: ${pathname}`);
- if (pipeline.getSettings().scripts.some((script) => script.stage === 'page')) {
- const hashedFilePath = pipeline.getInternals().entrySpecifierToBundleMap.get(PAGE_SCRIPT_ID);
- if (typeof hashedFilePath !== 'string') {
- throw new Error(`Cannot find the built path for ${PAGE_SCRIPT_ID}`);
- }
- const src = createAssetLink(hashedFilePath, manifest.base, manifest.assetsPrefix);
- scripts.add({
- props: { type: 'module', src },
- children: '',
- });
- }
+ // may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
+ const links = new Set<never>();
+ const scripts = createModuleScriptsSet(
+ hoistedScripts ? [hoistedScripts] : [],
+ manifest.base,
+ manifest.assetsPrefix
+ );
+ const styles = createStylesheetElementSet(_styles, manifest.base, manifest.assetsPrefix);
- // Add all injected scripts to the page.
- for (const script of pipeline.getSettings().scripts) {
- if (script.stage === 'head-inline') {
+ if (pipeline.getSettings().scripts.some((script) => script.stage === 'page')) {
+ const hashedFilePath = pipeline.getInternals().entrySpecifierToBundleMap.get(PAGE_SCRIPT_ID);
+ if (typeof hashedFilePath !== 'string') {
+ throw new Error(`Cannot find the built path for ${PAGE_SCRIPT_ID}`);
+ }
+ const src = createAssetLink(hashedFilePath, manifest.base, manifest.assetsPrefix);
scripts.add({
- props: {},
- children: script.content,
+ props: { type: 'module', src },
+ children: '',
});
}
- }
- const ssr = isServerLikeOutput(pipeline.getConfig());
- const url = getUrlForPath(
- pathname,
- pipeline.getConfig().base,
- pipeline.getStaticBuildOptions().origin,
- pipeline.getConfig().build.format,
- pageData.route.type
- );
+ // Add all injected scripts to the page.
+ for (const script of pipeline.getSettings().scripts) {
+ if (script.stage === 'head-inline') {
+ scripts.add({
+ props: {},
+ children: script.content,
+ });
+ }
+ }
- const request = createRequest({
- url,
- headers: new Headers(),
- logger: pipeline.getLogger(),
- ssr,
- });
- const i18n = pipeline.getConfig().experimental.i18n;
- const renderContext = await createRenderContext({
- pathname,
- request,
- componentMetadata: manifest.componentMetadata,
- scripts,
- styles,
- links,
- route: pageData.route,
- env: pipeline.getEnvironment(),
- mod,
- locales: i18n ? i18n.locales : undefined,
- });
+ const ssr = isServerLikeOutput(pipeline.getConfig());
+ const url = getUrlForPath(
+ pathname,
+ pipeline.getConfig().base,
+ pipeline.getStaticBuildOptions().origin,
+ pipeline.getConfig().build.format,
+ route.type
+ );
- let body: string | Uint8Array;
- let encoding: BufferEncoding | undefined;
+ const request = createRequest({
+ url,
+ headers: new Headers(),
+ logger: pipeline.getLogger(),
+ ssr,
+ });
+ const i18n = pipeline.getConfig().experimental.i18n;
+ const renderContext = await createRenderContext({
+ pathname,
+ request,
+ componentMetadata: manifest.componentMetadata,
+ scripts,
+ styles,
+ links,
+ route,
+ env: pipeline.getEnvironment(),
+ mod,
+ locales: i18n?.locales,
+ routingStrategy: i18n?.routingStrategy,
+ defaultLocale: i18n?.defaultLocale,
+ });
- let response: Response;
- try {
- response = await pipeline.renderRoute(renderContext, mod);
- } catch (err) {
- if (!AstroError.is(err) && !(err as SSRError).id && typeof err === 'object') {
- (err as SSRError).id = pageData.component;
- }
- throw err;
- }
+ let body: string | Uint8Array;
+ let encoding: BufferEncoding | undefined;
- if (response.status >= 300 && response.status < 400) {
- // If redirects is set to false, don't output the HTML
- if (!pipeline.getConfig().build.redirects) {
- return;
+ let response: Response;
+ try {
+ response = await pipeline.renderRoute(renderContext, mod);
+ } catch (err) {
+ if (!AstroError.is(err) && !(err as SSRError).id && typeof err === 'object') {
+ (err as SSRError).id = pageData.component;
+ }
+ throw err;
}
- const locationSite = getRedirectLocationOrThrow(response.headers);
- const siteURL = pipeline.getConfig().site;
- const location = siteURL ? new URL(locationSite, siteURL) : locationSite;
- const fromPath = new URL(renderContext.request.url).pathname;
- // A short delay causes Google to interpret the redirect as temporary.
- // https://developers.google.com/search/docs/crawling-indexing/301-redirects#metarefresh
- const delay = response.status === 302 ? 2 : 0;
- body = `<!doctype html>
+
+ if (response.status >= 300 && response.status < 400) {
+ // If redirects is set to false, don't output the HTML
+ if (!pipeline.getConfig().build.redirects) {
+ return;
+ }
+ const locationSite = getRedirectLocationOrThrow(response.headers);
+ const siteURL = pipeline.getConfig().site;
+ const location = siteURL ? new URL(locationSite, siteURL) : locationSite;
+ const fromPath = new URL(renderContext.request.url).pathname;
+ // A short delay causes Google to interpret the redirect as temporary.
+ // https://developers.google.com/search/docs/crawling-indexing/301-redirects#metarefresh
+ const delay = response.status === 302 ? 2 : 0;
+ body = `<!doctype html>
<title>Redirecting to: ${location}</title>
<meta http-equiv="refresh" content="${delay};url=${location}">
<meta name="robots" content="noindex">
@@ -597,26 +619,27 @@ async function generatePath(pathname: string, gopts: GeneratePathOptions, pipeli
<body>
<a href="${location}">Redirecting from <code>${fromPath}</code> to <code>${location}</code></a>
</body>`;
- if (pipeline.getConfig().compressHTML === true) {
- body = body.replaceAll('\n', '');
- }
- // A dynamic redirect, set the location so that integrations know about it.
- if (pageData.route.type !== 'redirect') {
- pageData.route.redirect = location.toString();
+ if (pipeline.getConfig().compressHTML === true) {
+ body = body.replaceAll('\n', '');
+ }
+ // A dynamic redirect, set the location so that integrations know about it.
+ if (route.type !== 'redirect') {
+ route.redirect = location.toString();
+ }
+ } else {
+ // If there's no body, do nothing
+ if (!response.body) return;
+ body = Buffer.from(await response.arrayBuffer());
+ encoding = (response.headers.get('X-Astro-Encoding') as BufferEncoding | null) ?? 'utf-8';
}
- } else {
- // If there's no body, do nothing
- if (!response.body) return;
- body = Buffer.from(await response.arrayBuffer());
- encoding = (response.headers.get('X-Astro-Encoding') as BufferEncoding | null) ?? 'utf-8';
- }
- const outFolder = getOutFolder(pipeline.getConfig(), pathname, pageData.route.type);
- const outFile = getOutFile(pipeline.getConfig(), outFolder, pathname, pageData.route.type);
- pageData.route.distURL = outFile;
+ const outFolder = getOutFolder(pipeline.getConfig(), pathname, route.type);
+ const outFile = getOutFile(pipeline.getConfig(), outFolder, pathname, route.type);
+ route.distURL = outFile;
- await fs.promises.mkdir(outFolder, { recursive: true });
- await fs.promises.writeFile(outFile, body, encoding);
+ await fs.promises.mkdir(outFolder, { recursive: true });
+ await fs.promises.writeFile(outFile, body, encoding);
+ }
}
/**
diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts
index 1dc38e735..3babef38f 100644
--- a/packages/astro/src/core/build/internal.ts
+++ b/packages/astro/src/core/build/internal.ts
@@ -2,7 +2,6 @@ import type { Rollup } from 'vite';
import type { RouteData, SSRResult } from '../../@types/astro.js';
import type { PageOptions } from '../../vite-plugin-astro/types.js';
import { prependForwardSlash, removeFileExtension } from '../path.js';
-import { routeIsFallback } from '../redirects/helpers.js';
import { viteID } from '../util.js';
import {
ASTRO_PAGE_RESOLVED_MODULE_ID,
@@ -38,17 +37,10 @@ export interface BuildInternals {
/**
* A map for page-specific information.
- * // TODO: Remove in Astro 4.0
- * @deprecated
*/
pagesByComponent: Map<string, PageBuildData>;
/**
- * TODO: Use this in Astro 4.0
- */
- pagesByComponents: Map<string, PageBuildData[]>;
-
- /**
* A map for page-specific output.
*/
pageOptionsByPage: Map<string, PageOptions>;
@@ -126,7 +118,6 @@ export function createBuildInternals(): BuildInternals {
entrySpecifierToBundleMap: new Map<string, string>(),
pageToBundleMap: new Map<string, string>(),
pagesByComponent: new Map(),
- pagesByComponents: new Map(),
pageOptionsByPage: new Map(),
pagesByViteID: new Map(),
pagesByClientOnly: new Map(),
@@ -152,16 +143,7 @@ export function trackPageData(
componentURL: URL
): void {
pageData.moduleSpecifier = componentModuleId;
- if (!routeIsFallback(pageData.route)) {
- internals.pagesByComponent.set(component, pageData);
- }
- const list = internals.pagesByComponents.get(component);
- if (list) {
- list.push(pageData);
- internals.pagesByComponents.set(component, list);
- } else {
- internals.pagesByComponents.set(component, [pageData]);
- }
+ internals.pagesByComponent.set(component, pageData);
internals.pagesByViteID.set(viteID(componentURL), pageData);
}
@@ -258,10 +240,8 @@ export function* eachPageData(internals: BuildInternals) {
}
export function* eachPageFromAllPages(allPages: AllPagesData): Generator<[string, PageBuildData]> {
- for (const [path, list] of Object.entries(allPages)) {
- for (const pageData of list) {
- yield [path, pageData];
- }
+ for (const [path, pageData] of Object.entries(allPages)) {
+ yield [path, pageData];
}
}
diff --git a/packages/astro/src/core/build/page-data.ts b/packages/astro/src/core/build/page-data.ts
index 7292cb4e8..89eca3ffc 100644
--- a/packages/astro/src/core/build/page-data.ts
+++ b/packages/astro/src/core/build/page-data.ts
@@ -47,29 +47,16 @@ export async function collectPagesData(
clearInterval(routeCollectionLogTimeout);
}, 10000);
builtPaths.add(route.pathname);
- if (allPages[route.component]) {
- allPages[route.component].push({
- component: route.component,
- route,
- moduleSpecifier: '',
- styles: [],
- propagatedStyles: new Map(),
- propagatedScripts: new Map(),
- hoistedScript: undefined,
- });
- } else {
- allPages[route.component] = [
- {
- component: route.component,
- route,
- moduleSpecifier: '',
- styles: [],
- propagatedStyles: new Map(),
- propagatedScripts: new Map(),
- hoistedScript: undefined,
- },
- ];
- }
+
+ allPages[route.component] = {
+ component: route.component,
+ route,
+ moduleSpecifier: '',
+ styles: [],
+ propagatedStyles: new Map(),
+ propagatedScripts: new Map(),
+ hoistedScript: undefined,
+ };
clearInterval(routeCollectionLogTimeout);
if (settings.config.output === 'static') {
@@ -84,29 +71,16 @@ export async function collectPagesData(
continue;
}
// dynamic route:
- if (allPages[route.component]) {
- allPages[route.component].push({
- component: route.component,
- route,
- moduleSpecifier: '',
- styles: [],
- propagatedStyles: new Map(),
- propagatedScripts: new Map(),
- hoistedScript: undefined,
- });
- } else {
- allPages[route.component] = [
- {
- component: route.component,
- route,
- moduleSpecifier: '',
- styles: [],
- propagatedStyles: new Map(),
- propagatedScripts: new Map(),
- hoistedScript: undefined,
- },
- ];
- }
+
+ allPages[route.component] = {
+ component: route.component,
+ route,
+ moduleSpecifier: '',
+ styles: [],
+ propagatedStyles: new Map(),
+ propagatedScripts: new Map(),
+ hoistedScript: undefined,
+ };
}
clearInterval(dataCollectionLogTimeout);
diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts
index 95404c6d6..2580e585e 100644
--- a/packages/astro/src/core/build/static-build.ts
+++ b/packages/astro/src/core/build/static-build.ts
@@ -51,17 +51,15 @@ export async function viteBuild(opts: StaticBuildOptions) {
// Build internals needed by the CSS plugin
const internals = createBuildInternals();
- for (const [component, pageDataList] of Object.entries(allPages)) {
- for (const pageData of pageDataList) {
- const astroModuleURL = new URL('./' + component, settings.config.root);
- const astroModuleId = prependForwardSlash(component);
+ for (const [component, pageData] of Object.entries(allPages)) {
+ const astroModuleURL = new URL('./' + component, settings.config.root);
+ const astroModuleId = prependForwardSlash(component);
- // Track the page data in internals
- trackPageData(internals, component, pageData, astroModuleId, astroModuleURL);
+ // Track the page data in internals
+ trackPageData(internals, component, pageData, astroModuleId, astroModuleURL);
- if (!routeIsRedirect(pageData.route)) {
- pageInput.add(astroModuleId);
- }
+ if (!routeIsRedirect(pageData.route)) {
+ pageInput.add(astroModuleId);
}
}
@@ -150,9 +148,7 @@ async function ssrBuild(
const { allPages, settings, viteConfig } = opts;
const ssr = isServerLikeOutput(settings.config);
const out = getOutputDirectory(settings.config);
- const routes = Object.values(allPages)
- .flat()
- .map((pageData) => pageData.route);
+ const routes = Object.values(allPages).flatMap((pageData) => pageData.route);
const isContentCache = !ssr && settings.config.experimental.contentCollectionCache;
const { lastVitePlugins, vitePlugins } = await container.runBeforeHook('server', input);
diff --git a/packages/astro/src/core/build/types.ts b/packages/astro/src/core/build/types.ts
index 00d6ce046..59fa06f6b 100644
--- a/packages/astro/src/core/build/types.ts
+++ b/packages/astro/src/core/build/types.ts
@@ -30,7 +30,8 @@ export interface PageBuildData {
hoistedScript: { type: 'inline' | 'external'; value: string } | undefined;
styles: Array<{ depth: number; order: number; sheet: StylesheetAsset }>;
}
-export type AllPagesData = Record<ComponentPath, PageBuildData[]>;
+
+export type AllPagesData = Record<ComponentPath, PageBuildData>;
/** Options for the static build */
export interface StaticBuildOptions {
diff --git a/packages/astro/src/core/endpoint/index.ts b/packages/astro/src/core/endpoint/index.ts
index d5484f0df..f9c61d053 100644
--- a/packages/astro/src/core/endpoint/index.ts
+++ b/packages/astro/src/core/endpoint/index.ts
@@ -12,7 +12,11 @@ import { ASTRO_VERSION } from '../constants.js';
import { AstroCookies, attachCookiesToResponse } from '../cookies/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import { callMiddleware } from '../middleware/callMiddleware.js';
-import { computePreferredLocale, computePreferredLocaleList } from '../render/context.js';
+import {
+ computeCurrentLocale,
+ computePreferredLocale,
+ computePreferredLocaleList,
+} from '../render/context.js';
import { type Environment, type RenderContext } from '../render/index.js';
const encoder = new TextEncoder();
@@ -27,6 +31,8 @@ type CreateAPIContext = {
props: Record<string, any>;
adapterName?: string;
locales: string[] | undefined;
+ routingStrategy: 'prefix-always' | 'prefix-other-locales' | undefined;
+ defaultLocale: string | undefined;
};
/**
@@ -41,9 +47,12 @@ export function createAPIContext({
props,
adapterName,
locales,
+ routingStrategy,
+ defaultLocale,
}: CreateAPIContext): APIContext {
let preferredLocale: string | undefined = undefined;
let preferredLocaleList: string[] | undefined = undefined;
+ let currentLocale: string | undefined = undefined;
const context = {
cookies: new AstroCookies(request),
@@ -83,6 +92,16 @@ export function createAPIContext({
return undefined;
},
+ get currentLocale(): string | undefined {
+ if (currentLocale) {
+ return currentLocale;
+ }
+ if (locales) {
+ currentLocale = computeCurrentLocale(request, locales, routingStrategy, defaultLocale);
+ }
+
+ return currentLocale;
+ },
url: new URL(request.url),
get clientAddress() {
if (clientAddressSymbol in request) {
@@ -153,8 +172,7 @@ export async function callEndpoint<MiddlewareResult = Response | EndpointOutput>
mod: EndpointHandler,
env: Environment,
ctx: RenderContext,
- onRequest: MiddlewareHandler<MiddlewareResult> | undefined,
- locales: undefined | string[]
+ onRequest: MiddlewareHandler<MiddlewareResult> | undefined
): Promise<Response> {
const context = createAPIContext({
request: ctx.request,
@@ -162,7 +180,9 @@ export async function callEndpoint<MiddlewareResult = Response | EndpointOutput>
props: ctx.props,
site: env.site,
adapterName: env.adapterName,
- locales,
+ routingStrategy: ctx.routingStrategy,
+ defaultLocale: ctx.defaultLocale,
+ locales: ctx.locales,
});
let response;
diff --git a/packages/astro/src/core/middleware/index.ts b/packages/astro/src/core/middleware/index.ts
index 77da30aee..c02761351 100644
--- a/packages/astro/src/core/middleware/index.ts
+++ b/packages/astro/src/core/middleware/index.ts
@@ -35,6 +35,8 @@ function createContext({ request, params, userDefinedLocales = [] }: CreateConte
props: {},
site: undefined,
locales: userDefinedLocales,
+ defaultLocale: undefined,
+ routingStrategy: undefined,
});
}
diff --git a/packages/astro/src/core/pipeline.ts b/packages/astro/src/core/pipeline.ts
index bd203b437..87f833ee5 100644
--- a/packages/astro/src/core/pipeline.ts
+++ b/packages/astro/src/core/pipeline.ts
@@ -128,6 +128,8 @@ export class Pipeline {
site: env.site,
adapterName: env.adapterName,
locales: renderContext.locales,
+ routingStrategy: renderContext.routingStrategy,
+ defaultLocale: renderContext.defaultLocale,
});
switch (renderContext.route.type) {
@@ -158,13 +160,7 @@ export class Pipeline {
}
}
case 'endpoint': {
- return await callEndpoint(
- mod as any as EndpointHandler,
- env,
- renderContext,
- onRequest,
- renderContext.locales
- );
+ return await callEndpoint(mod as any as EndpointHandler, env, renderContext, onRequest);
}
default:
throw new Error(`Couldn't find route of type [${renderContext.route.type}]`);
diff --git a/packages/astro/src/core/render/context.ts b/packages/astro/src/core/render/context.ts
index 851c41bc5..0f0bf39b0 100644
--- a/packages/astro/src/core/render/context.ts
+++ b/packages/astro/src/core/render/context.ts
@@ -29,6 +29,8 @@ export interface RenderContext {
props: Props;
locals?: object;
locales: string[] | undefined;
+ defaultLocale: string | undefined;
+ routingStrategy: 'prefix-always' | 'prefix-other-locales' | undefined;
}
export type CreateRenderContextArgs = Partial<
@@ -60,6 +62,8 @@ export async function createRenderContext(
params,
props,
locales: options.locales,
+ routingStrategy: options.routingStrategy,
+ defaultLocale: options.defaultLocale,
};
// We define a custom property, so we can check the value passed to locals
@@ -208,3 +212,23 @@ export function computePreferredLocaleList(request: Request, locales: string[])
return result;
}
+
+export function computeCurrentLocale(
+ request: Request,
+ locales: string[],
+ routingStrategy: 'prefix-always' | 'prefix-other-locales' | undefined,
+ defaultLocale: string | undefined
+): undefined | string {
+ const requestUrl = new URL(request.url);
+ for (const segment of requestUrl.pathname.split('/')) {
+ for (const locale of locales) {
+ if (normalizeTheLocale(locale) === normalizeTheLocale(segment)) {
+ return locale;
+ }
+ }
+ }
+ if (routingStrategy === 'prefix-other-locales') {
+ return defaultLocale;
+ }
+ return undefined;
+}
diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts
index a3235003f..5b120bb07 100644
--- a/packages/astro/src/core/render/core.ts
+++ b/packages/astro/src/core/render/core.ts
@@ -60,6 +60,8 @@ export async function renderPage({ mod, renderContext, env, cookies }: RenderPag
cookies,
locals: renderContext.locals ?? {},
locales: renderContext.locales,
+ defaultLocale: renderContext.defaultLocale,
+ routingStrategy: renderContext.routingStrategy,
});
// TODO: Remove in Astro 4.0
diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts
index f4a1b0769..2c37f38c4 100644
--- a/packages/astro/src/core/render/result.ts
+++ b/packages/astro/src/core/render/result.ts
@@ -12,7 +12,11 @@ import { chunkToString } from '../../runtime/server/render/index.js';
import { AstroCookies } from '../cookies/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import type { Logger } from '../logger/core.js';
-import { computePreferredLocale, computePreferredLocaleList } from './context.js';
+import {
+ computeCurrentLocale,
+ computePreferredLocale,
+ computePreferredLocaleList,
+} from './context.js';
const clientAddressSymbol = Symbol.for('astro.clientAddress');
const responseSentSymbol = Symbol.for('astro.responseSent');
@@ -47,6 +51,8 @@ export interface CreateResultArgs {
locals: App.Locals;
cookies?: AstroCookies;
locales: string[] | undefined;
+ defaultLocale: string | undefined;
+ routingStrategy: 'prefix-always' | 'prefix-other-locales' | undefined;
}
function getFunctionExpression(slot: any) {
@@ -148,6 +154,7 @@ export function createResult(args: CreateResultArgs): SSRResult {
let cookies: AstroCookies | undefined = args.cookies;
let preferredLocale: string | undefined = undefined;
let preferredLocaleList: string[] | undefined = undefined;
+ let currentLocale: string | undefined = undefined;
// Create the result object that will be passed into the render function.
// This object starts here as an empty shell (not yet the result) but then
@@ -218,6 +225,24 @@ export function createResult(args: CreateResultArgs): SSRResult {
return undefined;
},
+ get currentLocale(): string | undefined {
+ if (currentLocale) {
+ return currentLocale;
+ }
+ if (args.locales) {
+ currentLocale = computeCurrentLocale(
+ request,
+ args.locales,
+ args.routingStrategy,
+ args.defaultLocale
+ );
+ if (currentLocale) {
+ return currentLocale;
+ }
+ }
+
+ return undefined;
+ },
params,
props,
locals,
diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts
index ded3e13a8..9ab331504 100644
--- a/packages/astro/src/core/routing/manifest/create.ts
+++ b/packages/astro/src/core/routing/manifest/create.ts
@@ -347,6 +347,7 @@ export function createRouteManifest(
generate,
pathname: pathname || undefined,
prerender,
+ fallbackRoutes: [],
});
}
});
@@ -422,6 +423,7 @@ export function createRouteManifest(
generate,
pathname: pathname || void 0,
prerender: prerenderInjected ?? prerender,
+ fallbackRoutes: [],
});
});
@@ -461,6 +463,7 @@ export function createRouteManifest(
prerender: false,
redirect: to,
redirectRoute: routes.find((r) => r.route === to),
+ fallbackRoutes: [],
};
const lastSegmentIsDynamic = (r: RouteData) => !!r.segments.at(-1)?.at(-1)?.dynamic;
@@ -549,6 +552,7 @@ export function createRouteManifest(
validateSegment(s);
return getParts(s, route);
});
+
routes.push({
...indexDefaultRoute,
pathname,
@@ -622,14 +626,21 @@ export function createRouteManifest(
validateSegment(s);
return getParts(s, route);
});
- routes.push({
- ...fallbackToRoute,
- pathname,
- route,
- segments,
- pattern: getPattern(segments, config, config.trailingSlash),
- type: 'fallback',
- });
+
+ const index = routes.findIndex((r) => r === fallbackToRoute);
+ if (index) {
+ const fallbackRoute: RouteData = {
+ ...fallbackToRoute,
+ pathname,
+ route,
+ segments,
+ pattern: getPattern(segments, config, config.trailingSlash),
+ type: 'fallback',
+ fallbackRoutes: [],
+ };
+ const routeData = routes[index];
+ routeData.fallbackRoutes.push(fallbackRoute);
+ }
}
}
}
diff --git a/packages/astro/src/core/routing/manifest/serialization.ts b/packages/astro/src/core/routing/manifest/serialization.ts
index 71ffc221d..f70aa84dd 100644
--- a/packages/astro/src/core/routing/manifest/serialization.ts
+++ b/packages/astro/src/core/routing/manifest/serialization.ts
@@ -13,6 +13,9 @@ export function serializeRouteData(
redirectRoute: routeData.redirectRoute
? serializeRouteData(routeData.redirectRoute, trailingSlash)
: undefined,
+ fallbackRoutes: routeData.fallbackRoutes.map((fallbackRoute) => {
+ return serializeRouteData(fallbackRoute, trailingSlash);
+ }),
_meta: { trailingSlash },
};
}
@@ -32,5 +35,8 @@ export function deserializeRouteData(rawRouteData: SerializedRouteData): RouteDa
redirectRoute: rawRouteData.redirectRoute
? deserializeRouteData(rawRouteData.redirectRoute)
: undefined,
+ fallbackRoutes: rawRouteData.fallbackRoutes.map((fallback) => {
+ return deserializeRouteData(fallback);
+ }),
};
}
diff --git a/packages/astro/src/core/routing/match.ts b/packages/astro/src/core/routing/match.ts
index 9b91e1e9a..97659253e 100644
--- a/packages/astro/src/core/routing/match.ts
+++ b/packages/astro/src/core/routing/match.ts
@@ -2,7 +2,13 @@ import type { ManifestData, RouteData } from '../../@types/astro.js';
/** Find matching route from pathname */
export function matchRoute(pathname: string, manifest: ManifestData): RouteData | undefined {
- return manifest.routes.find((route) => route.pattern.test(decodeURI(pathname)));
+ const decodedPathname = decodeURI(pathname);
+ return manifest.routes.find((route) => {
+ return (
+ route.pattern.test(decodedPathname) ||
+ route.fallbackRoutes.some((fallbackRoute) => fallbackRoute.pattern.test(decodedPathname))
+ );
+ });
}
/** Finds all matching routes from pathname */
diff --git a/packages/astro/src/prefetch/index.ts b/packages/astro/src/prefetch/index.ts
index f47cff060..573efe573 100644
--- a/packages/astro/src/prefetch/index.ts
+++ b/packages/astro/src/prefetch/index.ts
@@ -56,7 +56,7 @@ function initTapStrategy() {
event,
(e) => {
if (elMatchesStrategy(e.target, 'tap')) {
- prefetch(e.target.href, { with: 'fetch' });
+ prefetch(e.target.href, { with: 'fetch', ignoreSlowConnection: true });
}
},
{ passive: true }
@@ -176,6 +176,10 @@ export interface PrefetchOptions {
* - `'fetch'`: use `fetch()`, has higher loading priority.
*/
with?: 'link' | 'fetch';
+ /**
+ * Should prefetch even on data saver mode or slow connection. (default `false`)
+ */
+ ignoreSlowConnection?: boolean;
}
/**
@@ -190,7 +194,8 @@ export interface PrefetchOptions {
* @param opts Additional options for prefetching.
*/
export function prefetch(url: string, opts?: PrefetchOptions) {
- if (!canPrefetchUrl(url)) return;
+ const ignoreSlowConnection = opts?.ignoreSlowConnection ?? false;
+ if (!canPrefetchUrl(url, ignoreSlowConnection)) return;
prefetchedUrls.add(url);
const priority = opts?.with ?? 'link';
@@ -211,15 +216,11 @@ export function prefetch(url: string, opts?: PrefetchOptions) {
}
}
-function canPrefetchUrl(url: string) {
+function canPrefetchUrl(url: string, ignoreSlowConnection: boolean) {
// Skip prefetch if offline
if (!navigator.onLine) return false;
- if ('connection' in navigator) {
- // Untyped Chrome-only feature: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/connection
- const conn = navigator.connection as any;
- // Skip prefetch if using data saver mode or slow connection
- if (conn.saveData || /(2|3)g/.test(conn.effectiveType)) return false;
- }
+ // Skip prefetch if using data saver mode or slow connection
+ if (!ignoreSlowConnection && isSlowConnection()) return false;
// Else check if URL is within the same origin, not the current page, and not already prefetched
try {
const urlObj = new URL(url, location.href);
@@ -241,6 +242,12 @@ function elMatchesStrategy(el: EventTarget | null, strategy: string): el is HTML
if (attrValue === 'false') {
return false;
}
+
+ // Fallback to tap strategy if using data saver mode or slow connection
+ if (strategy === 'tap' && (attrValue != null || prefetchAll) && isSlowConnection()) {
+ return true;
+ }
+
// If anchor has no dataset but we want to prefetch all, or has dataset but no value,
// check against fallback default strategy
if ((attrValue == null && prefetchAll) || attrValue === '') {
@@ -254,6 +261,15 @@ function elMatchesStrategy(el: EventTarget | null, strategy: string): el is HTML
return false;
}
+function isSlowConnection() {
+ if ('connection' in navigator) {
+ // Untyped Chrome-only feature: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/connection
+ const conn = navigator.connection as any;
+ return conn.saveData || /(2|3)g/.test(conn.effectiveType);
+ }
+ return false;
+}
+
/**
* Listen to page loads and handle Astro's View Transition specific events
*/
diff --git a/packages/astro/src/runtime/server/transition.ts b/packages/astro/src/runtime/server/transition.ts
index 17eece1d9..d38a0eac6 100644
--- a/packages/astro/src/runtime/server/transition.ts
+++ b/packages/astro/src/runtime/server/transition.ts
@@ -1,7 +1,9 @@
import type {
SSRResult,
TransitionAnimation,
+ TransitionAnimationPair,
TransitionAnimationValue,
+ TransitionDirectionalAnimations,
} from '../../@types/astro.js';
import { fade, slide } from '../../transitions/index.js';
import { markHTMLString } from './escape.js';
@@ -34,6 +36,19 @@ const getAnimations = (name: TransitionAnimationValue) => {
if (typeof name === 'object') return name;
};
+const addPairs = (
+ animations: TransitionDirectionalAnimations | Record<string, TransitionAnimationPair>,
+ stylesheet: ViewTransitionStyleSheet
+) => {
+ for (const [direction, images] of Object.entries(animations) as Entries<typeof animations>) {
+ for (const [image, rules] of Object.entries(images) as Entries<
+ (typeof animations)[typeof direction]
+ >) {
+ stylesheet.addAnimationPair(direction, image, rules);
+ }
+ }
+};
+
export function renderTransition(
result: SSRResult,
hash: string,
@@ -48,13 +63,7 @@ export function renderTransition(
const animations = getAnimations(animationName);
if (animations) {
- for (const [direction, images] of Object.entries(animations) as Entries<typeof animations>) {
- for (const [image, rules] of Object.entries(images) as Entries<
- (typeof animations)[typeof direction]
- >) {
- sheet.addAnimationPair(direction, image, rules);
- }
- }
+ addPairs(animations, sheet);
} else if (animationName === 'none') {
sheet.addFallback('old', 'animation: none; mix-blend-mode: normal;');
sheet.addModern('old', 'animation: none; opacity: 0; mix-blend-mode: normal;');
@@ -65,6 +74,19 @@ export function renderTransition(
return scope;
}
+export function createAnimationScope(
+ transitionName: string,
+ animations: Record<string, TransitionAnimationPair>
+) {
+ const hash = Math.random().toString(36).slice(2, 8);
+ const scope = `astro-${hash}`;
+ const sheet = new ViewTransitionStyleSheet(scope, transitionName);
+
+ addPairs(animations, sheet);
+
+ return { scope, styles: sheet.toString().replaceAll('"', '') };
+}
+
class ViewTransitionStyleSheet {
private modern: string[] = [];
private fallback: string[] = [];
@@ -113,13 +135,18 @@ class ViewTransitionStyleSheet {
}
addAnimationPair(
- direction: 'forwards' | 'backwards',
+ direction: 'forwards' | 'backwards' | string,
image: 'old' | 'new',
rules: TransitionAnimation | TransitionAnimation[]
) {
const { scope, name } = this;
const animation = stringifyAnimation(rules);
- const prefix = direction === 'backwards' ? `[data-astro-transition=back]` : '';
+ const prefix =
+ direction === 'backwards'
+ ? `[data-astro-transition=back]`
+ : direction === 'forwards'
+ ? ''
+ : `[data-astro-transition=${direction}]`;
this.addRule('modern', `${prefix}::view-transition-${image}(${name}) { ${animation} }`);
this.addRule(
'fallback',
diff --git a/packages/astro/src/transitions/events.ts b/packages/astro/src/transitions/events.ts
new file mode 100644
index 000000000..b3921b31f
--- /dev/null
+++ b/packages/astro/src/transitions/events.ts
@@ -0,0 +1,184 @@
+import { updateScrollPosition } from './router.js';
+import type { Direction, NavigationTypeString } from './types.js';
+
+export const TRANSITION_BEFORE_PREPARATION = 'astro:before-preparation';
+export const TRANSITION_AFTER_PREPARATION = 'astro:after-preparation';
+export const TRANSITION_BEFORE_SWAP = 'astro:before-swap';
+export const TRANSITION_AFTER_SWAP = 'astro:after-swap';
+export const TRANSITION_PAGE_LOAD = 'astro:page-load';
+
+type Events =
+ | typeof TRANSITION_AFTER_PREPARATION
+ | typeof TRANSITION_AFTER_SWAP
+ | typeof TRANSITION_PAGE_LOAD;
+export const triggerEvent = (name: Events) => document.dispatchEvent(new Event(name));
+export const onPageLoad = () => triggerEvent(TRANSITION_PAGE_LOAD);
+
+/*
+ * Common stuff
+ */
+class BeforeEvent extends Event {
+ readonly from: URL;
+ to: URL;
+ direction: Direction | string;
+ readonly navigationType: NavigationTypeString;
+ readonly sourceElement: Element | undefined;
+ readonly info: any;
+ newDocument: Document;
+
+ constructor(
+ type: string,
+ eventInitDict: EventInit | undefined,
+ from: URL,
+ to: URL,
+ direction: Direction | string,
+ navigationType: NavigationTypeString,
+ sourceElement: Element | undefined,
+ info: any,
+ newDocument: Document
+ ) {
+ super(type, eventInitDict);
+ this.from = from;
+ this.to = to;
+ this.direction = direction;
+ this.navigationType = navigationType;
+ this.sourceElement = sourceElement;
+ this.info = info;
+ this.newDocument = newDocument;
+
+ Object.defineProperties(this, {
+ from: { enumerable: true },
+ to: { enumerable: true, writable: true },
+ direction: { enumerable: true, writable: true },
+ navigationType: { enumerable: true },
+ sourceElement: { enumerable: true },
+ info: { enumerable: true },
+ newDocument: { enumerable: true, writable: true },
+ });
+ }
+}
+
+/*
+ * TransitionBeforePreparationEvent
+
+ */
+export const isTransitionBeforePreparationEvent = (
+ value: any
+): value is TransitionBeforePreparationEvent => value.type === TRANSITION_BEFORE_PREPARATION;
+export class TransitionBeforePreparationEvent extends BeforeEvent {
+ formData: FormData | undefined;
+ loader: () => Promise<void>;
+ constructor(
+ from: URL,
+ to: URL,
+ direction: Direction | string,
+ navigationType: NavigationTypeString,
+ sourceElement: Element | undefined,
+ info: any,
+ newDocument: Document,
+ formData: FormData | undefined,
+ loader: (event: TransitionBeforePreparationEvent) => Promise<void>
+ ) {
+ super(
+ TRANSITION_BEFORE_PREPARATION,
+ { cancelable: true },
+ from,
+ to,
+ direction,
+ navigationType,
+ sourceElement,
+ info,
+ newDocument
+ );
+ this.formData = formData;
+ this.loader = loader.bind(this, this);
+ Object.defineProperties(this, {
+ formData: { enumerable: true },
+ loader: { enumerable: true, writable: true },
+ });
+ }
+}
+
+/*
+ * TransitionBeforeSwapEvent
+ */
+
+export const isTransitionBeforeSwapEvent = (value: any): value is TransitionBeforeSwapEvent =>
+ value.type === TRANSITION_BEFORE_SWAP;
+export class TransitionBeforeSwapEvent extends BeforeEvent {
+ readonly direction: Direction | string;
+ readonly viewTransition: ViewTransition;
+ swap: () => void;
+
+ constructor(
+ afterPreparation: BeforeEvent,
+ viewTransition: ViewTransition,
+ swap: (event: TransitionBeforeSwapEvent) => void
+ ) {
+ super(
+ TRANSITION_BEFORE_SWAP,
+ undefined,
+ afterPreparation.from,
+ afterPreparation.to,
+ afterPreparation.direction,
+ afterPreparation.navigationType,
+ afterPreparation.sourceElement,
+ afterPreparation.info,
+ afterPreparation.newDocument
+ );
+ this.direction = afterPreparation.direction;
+ this.viewTransition = viewTransition;
+ this.swap = swap.bind(this, this);
+
+ Object.defineProperties(this, {
+ direction: { enumerable: true },
+ viewTransition: { enumerable: true },
+ swap: { enumerable: true, writable: true },
+ });
+ }
+}
+
+export async function doPreparation(
+ from: URL,
+ to: URL,
+ direction: Direction | string,
+ navigationType: NavigationTypeString,
+ sourceElement: Element | undefined,
+ info: any,
+ formData: FormData | undefined,
+ defaultLoader: (event: TransitionBeforePreparationEvent) => Promise<void>
+) {
+ const event = new TransitionBeforePreparationEvent(
+ from,
+ to,
+ direction,
+ navigationType,
+ sourceElement,
+ info,
+ window.document,
+ formData,
+ defaultLoader
+ );
+ if (document.dispatchEvent(event)) {
+ await event.loader();
+ if (!event.defaultPrevented) {
+ triggerEvent(TRANSITION_AFTER_PREPARATION);
+ if (event.navigationType !== 'traverse') {
+ // save the current scroll position before we change the DOM and transition to the new page
+ updateScrollPosition({ scrollX, scrollY });
+ }
+ }
+ }
+ return event;
+}
+
+export async function doSwap(
+ afterPreparation: BeforeEvent,
+ viewTransition: ViewTransition,
+ defaultSwap: (event: TransitionBeforeSwapEvent) => void
+) {
+ const event = new TransitionBeforeSwapEvent(afterPreparation, viewTransition, defaultSwap);
+ document.dispatchEvent(event);
+ event.swap();
+ return event;
+}
diff --git a/packages/astro/src/transitions/index.ts b/packages/astro/src/transitions/index.ts
index 0a58d2d4b..d87052f2d 100644
--- a/packages/astro/src/transitions/index.ts
+++ b/packages/astro/src/transitions/index.ts
@@ -1,4 +1,5 @@
import type { TransitionAnimationPair, TransitionDirectionalAnimations } from '../@types/astro.js';
+export { createAnimationScope } from '../runtime/server/transition.js';
const EASE_IN_OUT_QUART = 'cubic-bezier(0.76, 0, 0.24, 1)';
diff --git a/packages/astro/src/transitions/router.ts b/packages/astro/src/transitions/router.ts
index c4da38c2c..a97abfcf7 100644
--- a/packages/astro/src/transitions/router.ts
+++ b/packages/astro/src/transitions/router.ts
@@ -1,23 +1,27 @@
-export type Fallback = 'none' | 'animate' | 'swap';
-export type Direction = 'forward' | 'back';
-export type Options = {
- history?: 'auto' | 'push' | 'replace';
- formData?: FormData;
-};
+import {
+ TRANSITION_AFTER_SWAP,
+ TransitionBeforeSwapEvent,
+ doPreparation,
+ doSwap,
+ type TransitionBeforePreparationEvent,
+} from './events.js';
+import type { Direction, Fallback, Options } from './types.js';
type State = {
index: number;
scrollX: number;
scrollY: number;
- intraPage?: boolean;
};
type Events = 'astro:page-load' | 'astro:after-swap';
// only update history entries that are managed by us
// leave other entries alone and do not accidently add state.
-const updateScrollPosition = (positions: { scrollX: number; scrollY: number }) =>
- history.state && history.replaceState({ ...history.state, ...positions }, '');
-
+export const updateScrollPosition = (positions: { scrollX: number; scrollY: number }) => {
+ if (history.state) {
+ history.scrollRestoration = 'manual';
+ history.replaceState({ ...history.state, ...positions }, '');
+ }
+};
const inBrowser = import.meta.env.SSR === false;
export const supportsViewTransitions = inBrowser && !!document.startViewTransition;
@@ -25,8 +29,21 @@ export const supportsViewTransitions = inBrowser && !!document.startViewTransiti
export const transitionEnabledOnThisPage = () =>
inBrowser && !!document.querySelector('[name="astro-view-transitions-enabled"]');
-const samePage = (otherLocation: URL) =>
- location.pathname === otherLocation.pathname && location.search === otherLocation.search;
+const samePage = (thisLocation: URL, otherLocation: URL) =>
+ thisLocation.origin === otherLocation.origin &&
+ thisLocation.pathname === otherLocation.pathname &&
+ thisLocation.search === otherLocation.search;
+
+// When we traverse the history, the window.location is already set to the new location.
+// This variable tells us where we came from
+let originalLocation: URL;
+// The result of startViewTransition (browser or simulation)
+let viewTransition: ViewTransition | undefined;
+// skip transition flag for fallback simulation
+let skipTransition = false;
+// The resolve function of the finished promise for fallback simulation
+let viewTransitionFinished: () => void;
+
const triggerEvent = (name: Events) => document.dispatchEvent(new Event(name));
const onPageLoad = () => triggerEvent('astro:page-load');
const announce = () => {
@@ -48,6 +65,9 @@ const announce = () => {
};
const PERSIST_ATTR = 'data-astro-transition-persist';
+const DIRECTION_ATTR = 'data-astro-transition';
+const OLD_NEW_ATTR = 'data-astro-transition-fallback';
+
const VITE_ID = 'data-vite-dev-id';
let parser: DOMParser;
@@ -66,7 +86,8 @@ if (inBrowser) {
} else if (transitionEnabledOnThisPage()) {
// This page is loaded from the browser addressbar or via a link from extern,
// it needs a state in the history
- history.replaceState({ index: currentHistoryIndex, scrollX, scrollY, intraPage: false }, '');
+ history.replaceState({ index: currentHistoryIndex, scrollX, scrollY }, '');
+ history.scrollRestoration = 'manual';
}
}
@@ -147,50 +168,61 @@ function runScripts() {
return wait;
}
-function isInfinite(animation: Animation) {
- const effect = animation.effect;
- if (!effect || !(effect instanceof KeyframeEffect) || !effect.target) return false;
- const style = window.getComputedStyle(effect.target, effect.pseudoElement);
- return style.animationIterationCount === 'infinite';
-}
-
// Add a new entry to the browser history. This also sets the new page in the browser addressbar.
// Sets the scroll position according to the hash fragment of the new location.
-const moveToLocation = (toLocation: URL, replace: boolean, intraPage: boolean) => {
- const fresh = !samePage(toLocation);
+const moveToLocation = (to: URL, from: URL, options: Options, historyState?: State) => {
+ const intraPage = samePage(from, to);
+
let scrolledToTop = false;
- if (toLocation.href !== location.href) {
- if (replace) {
- history.replaceState({ ...history.state }, '', toLocation.href);
+ if (to.href !== location.href && !historyState) {
+ if (options.history === 'replace') {
+ const current = history.state;
+ history.replaceState(
+ {
+ ...options.state,
+ index: current.index,
+ scrollX: current.scrollX,
+ scrollY: current.scrollY,
+ },
+ '',
+ to.href
+ );
} else {
- history.replaceState({ ...history.state, intraPage }, '');
history.pushState(
- { index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 },
+ { ...options.state, index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 },
'',
- toLocation.href
+ to.href
);
}
- // now we are on the new page for non-history navigations!
- // (with history navigation page change happens before popstate is fired)
- // freshly loaded pages start from the top
- if (fresh) {
- scrollTo({ left: 0, top: 0, behavior: 'instant' });
- scrolledToTop = true;
- }
+ history.scrollRestoration = 'manual';
}
- if (toLocation.hash) {
- // because we are already on the target page ...
- // ... what comes next is a intra-page navigation
- // that won't reload the page but instead scroll to the fragment
- location.href = toLocation.href;
+ // now we are on the new page for non-history navigations!
+ // (with history navigation page change happens before popstate is fired)
+ originalLocation = to;
+
+ // freshly loaded pages start from the top
+ if (!intraPage) {
+ scrollTo({ left: 0, top: 0, behavior: 'instant' });
+ scrolledToTop = true;
+ }
+
+ if (historyState) {
+ scrollTo(historyState.scrollX, historyState.scrollY);
} else {
- if (!scrolledToTop) {
- scrollTo({ left: 0, top: 0, behavior: 'instant' });
+ if (to.hash) {
+ // because we are already on the target page ...
+ // ... what comes next is a intra-page navigation
+ // that won't reload the page but instead scroll to the fragment
+ location.href = to.href;
+ } else {
+ if (!scrolledToTop) {
+ scrollTo({ left: 0, top: 0, behavior: 'instant' });
+ }
}
}
};
-function stylePreloadLinks(newDocument: Document) {
+function preloadStyleLinks(newDocument: Document) {
const links: Promise<any>[] = [];
for (const el of newDocument.querySelectorAll('head link[rel=stylesheet]')) {
// Do not preload links that are already on the page.
@@ -221,24 +253,23 @@ function stylePreloadLinks(newDocument: Document) {
// if popState is given, this holds the scroll position for history navigation
// if fallback === "animate" then simulate view transitions
async function updateDOM(
- newDocument: Document,
- toLocation: URL,
+ preparationEvent: TransitionBeforePreparationEvent,
options: Options,
- popState?: State,
+ historyState?: State,
fallback?: Fallback
) {
// Check for a head element that should persist and returns it,
// either because it has the data attribute or is a link el.
// Returns null if the element is not part of the new head, undefined if it should be left alone.
- const persistedHeadElement = (el: HTMLElement): Element | null => {
+ const persistedHeadElement = (el: HTMLElement, newDoc: Document): Element | null => {
const id = el.getAttribute(PERSIST_ATTR);
- const newEl = id && newDocument.head.querySelector(`[${PERSIST_ATTR}="${id}"]`);
+ const newEl = id && newDoc.head.querySelector(`[${PERSIST_ATTR}="${id}"]`);
if (newEl) {
return newEl;
}
if (el.matches('link[rel=stylesheet]')) {
const href = el.getAttribute('href');
- return newDocument.head.querySelector(`link[rel=stylesheet][href="${href}"]`);
+ return newDoc.head.querySelector(`link[rel=stylesheet][href="${href}"]`);
}
return null;
};
@@ -282,22 +313,22 @@ async function updateDOM(
}
};
- const swap = () => {
+ const defaultSwap = (beforeSwapEvent: TransitionBeforeSwapEvent) => {
// swap attributes of the html element
// - delete all attributes from the current document
// - insert all attributes from doc
// - reinsert all original attributes that are named 'data-astro-*'
const html = document.documentElement;
- const astro = [...html.attributes].filter(
+ const astroAttributes = [...html.attributes].filter(
({ name }) => (html.removeAttribute(name), name.startsWith('data-astro-'))
);
- [...newDocument.documentElement.attributes, ...astro].forEach(({ name, value }) =>
- html.setAttribute(name, value)
+ [...beforeSwapEvent.newDocument.documentElement.attributes, ...astroAttributes].forEach(
+ ({ name, value }) => html.setAttribute(name, value)
);
// Replace scripts in both the head and body.
for (const s1 of document.scripts) {
- for (const s2 of newDocument.scripts) {
+ for (const s2 of beforeSwapEvent.newDocument.scripts) {
if (
// Inline
(!s1.src && s1.textContent === s2.textContent) ||
@@ -313,7 +344,7 @@ async function updateDOM(
// Swap head
for (const el of Array.from(document.head.children)) {
- const newEl = persistedHeadElement(el as HTMLElement);
+ const newEl = persistedHeadElement(el as HTMLElement, beforeSwapEvent.newDocument);
// If the element exists in the document already, remove it
// from the new document and leave the current node alone
if (newEl) {
@@ -325,7 +356,7 @@ async function updateDOM(
}
// Everything left in the new head is new, append it all.
- document.head.append(...newDocument.head.children);
+ document.head.append(...beforeSwapEvent.newDocument.head.children);
// Persist elements in the existing body
const oldBody = document.body;
@@ -333,7 +364,7 @@ async function updateDOM(
const savedFocus = saveFocus();
// this will reset scroll Position
- document.body.replaceWith(newDocument.body);
+ document.body.replaceWith(beforeSwapEvent.newDocument.body);
for (const el of oldBody.querySelectorAll(`[${PERSIST_ATTR}]`)) {
const id = el.getAttribute(PERSIST_ATTR);
@@ -345,103 +376,187 @@ async function updateDOM(
}
}
restoreFocus(savedFocus);
-
- if (popState) {
- scrollTo(popState.scrollX, popState.scrollY); // usings 'auto' scrollBehavior
- } else {
- moveToLocation(toLocation, options.history === 'replace', false);
- }
-
- triggerEvent('astro:after-swap');
};
- const links = stylePreloadLinks(newDocument);
- links.length && (await Promise.all(links));
-
- if (fallback === 'animate') {
+ async function animate(phase: string) {
+ function isInfinite(animation: Animation) {
+ const effect = animation.effect;
+ if (!effect || !(effect instanceof KeyframeEffect) || !effect.target) return false;
+ const style = window.getComputedStyle(effect.target, effect.pseudoElement);
+ return style.animationIterationCount === 'infinite';
+ }
// Trigger the animations
const currentAnimations = document.getAnimations();
- document.documentElement.dataset.astroTransitionFallback = 'old';
- const newAnimations = document
- .getAnimations()
- .filter((a) => !currentAnimations.includes(a) && !isInfinite(a));
- const finished = Promise.all(newAnimations.map((a) => a.finished));
- await finished;
- swap();
- document.documentElement.dataset.astroTransitionFallback = 'new';
+ document.documentElement.setAttribute(OLD_NEW_ATTR, phase);
+ const nextAnimations = document.getAnimations();
+ const newAnimations = nextAnimations.filter(
+ (a) => !currentAnimations.includes(a) && !isInfinite(a)
+ );
+ return Promise.all(newAnimations.map((a) => a.finished));
+ }
+
+ if (!skipTransition) {
+ document.documentElement.setAttribute(DIRECTION_ATTR, preparationEvent.direction);
+
+ if (fallback === 'animate') {
+ await animate('old');
+ }
} else {
- swap();
+ // that's what Chrome does
+ throw new DOMException('Transition was skipped');
+ }
+
+ const swapEvent = await doSwap(preparationEvent, viewTransition!, defaultSwap);
+ moveToLocation(swapEvent.to, swapEvent.from, options, historyState);
+ triggerEvent(TRANSITION_AFTER_SWAP);
+
+ if (fallback === 'animate' && !skipTransition) {
+ animate('new').then(() => viewTransitionFinished());
}
}
async function transition(
direction: Direction,
- toLocation: URL,
+ from: URL,
+ to: URL,
options: Options,
- popState?: State
+ historyState?: State
) {
- let finished: Promise<void>;
- const href = toLocation.href;
- const init: RequestInit = {};
- if (options.formData) {
- init.method = 'POST';
- init.body = options.formData;
+ const navigationType = historyState
+ ? 'traverse'
+ : options.history === 'replace'
+ ? 'replace'
+ : 'push';
+
+ if (samePage(from, to) && !options.formData /* not yet: && to.hash*/) {
+ if (navigationType !== 'traverse') {
+ updateScrollPosition({ scrollX, scrollY });
+ }
+ moveToLocation(to, from, options, historyState);
+ return;
}
- const response = await fetchHTML(href, init);
- // If there is a problem fetching the new page, just do an MPA navigation to it.
- if (response === null) {
- location.href = href;
+
+ const prepEvent = await doPreparation(
+ from,
+ to,
+ direction,
+ navigationType,
+ options.sourceElement,
+ options.info,
+ options.formData,
+ defaultLoader
+ );
+ if (prepEvent.defaultPrevented) {
+ location.href = to.href;
return;
}
- // if there was a redirection, show the final URL in the browser's address bar
- if (response.redirected) {
- toLocation = new URL(response.redirected);
+
+ function pageMustReload(preparationEvent: TransitionBeforePreparationEvent) {
+ return (
+ preparationEvent.to.hash === '' ||
+ !samePage(preparationEvent.from, preparationEvent.to) ||
+ preparationEvent.sourceElement instanceof HTMLFormElement
+ );
}
- parser ??= new DOMParser();
+ async function defaultLoader(preparationEvent: TransitionBeforePreparationEvent) {
+ if (pageMustReload(preparationEvent)) {
+ const href = preparationEvent.to.href;
+ const init: RequestInit = {};
+ if (preparationEvent.formData) {
+ init.method = 'POST';
+ init.body = preparationEvent.formData;
+ }
+ const response = await fetchHTML(href, init);
+ // If there is a problem fetching the new page, just do an MPA navigation to it.
+ if (response === null) {
+ preparationEvent.preventDefault();
+ return;
+ }
+ // if there was a redirection, show the final URL in the browser's address bar
+ if (response.redirected) {
+ preparationEvent.to = new URL(response.redirected);
+ }
+
+ parser ??= new DOMParser();
- const newDocument = parser.parseFromString(response.html, response.mediaType);
- // The next line might look like a hack,
- // but it is actually necessary as noscript elements
- // and their contents are returned as markup by the parser,
- // see https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString
- newDocument.querySelectorAll('noscript').forEach((el) => el.remove());
+ preparationEvent.newDocument = parser.parseFromString(response.html, response.mediaType);
+ // The next line might look like a hack,
+ // but it is actually necessary as noscript elements
+ // and their contents are returned as markup by the parser,
+ // see https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString
+ preparationEvent.newDocument.querySelectorAll('noscript').forEach((el) => el.remove());
- // If ViewTransitions is not enabled on the incoming page, do a full page load to it.
- // Unless this was a form submission, in which case we do not want to trigger another mutation.
- if (!newDocument.querySelector('[name="astro-view-transitions-enabled"]') && !options.formData) {
- location.href = href;
- return;
- }
+ // If ViewTransitions is not enabled on the incoming page, do a full page load to it.
+ // Unless this was a form submission, in which case we do not want to trigger another mutation.
+ if (
+ !preparationEvent.newDocument.querySelector('[name="astro-view-transitions-enabled"]') &&
+ !preparationEvent.formData
+ ) {
+ preparationEvent.preventDefault();
+ return;
+ }
- if (import.meta.env.DEV) await prepareForClientOnlyComponents(newDocument, toLocation);
+ const links = preloadStyleLinks(preparationEvent.newDocument);
+ links.length && (await Promise.all(links));
- if (!popState) {
- // save the current scroll position before we change the DOM and transition to the new page
- history.replaceState({ ...history.state, scrollX, scrollY }, '');
+ if (import.meta.env.DEV)
+ await prepareForClientOnlyComponents(preparationEvent.newDocument, preparationEvent.to);
+ } else {
+ preparationEvent.newDocument = document;
+ return;
+ }
}
- document.documentElement.dataset.astroTransition = direction;
+
+ skipTransition = false;
if (supportsViewTransitions) {
- finished = document.startViewTransition(() =>
- updateDOM(newDocument, toLocation, options, popState)
- ).finished;
+ viewTransition = document.startViewTransition(
+ async () => await updateDOM(prepEvent, options, historyState)
+ );
} else {
- finished = updateDOM(newDocument, toLocation, options, popState, getFallback());
+ const updateDone = (async () => {
+ // immediatelly paused to setup the ViewTransition object for Fallback mode
+ await new Promise((r) => setTimeout(r));
+ await updateDOM(prepEvent, options, historyState, getFallback());
+ })();
+
+ // When the updateDone promise is settled,
+ // we have run and awaited all swap functions and the after-swap event
+ // This qualifies for "updateCallbackDone".
+ //
+ // For the build in ViewTransition, "ready" settles shortly after "updateCallbackDone",
+ // i.e. after all pseudo elements are created and the animation is about to start.
+ // In simulation mode the "old" animation starts before swap,
+ // the "new" animation starts after swap. That is not really comparable.
+ // Thus we go with "very, very shortly after updateCallbackDone" and make both equal.
+ //
+ // "finished" resolves after all animations are done.
+
+ viewTransition = {
+ updateCallbackDone: updateDone, // this is about correct
+ ready: updateDone, // good enough
+ finished: new Promise((r) => (viewTransitionFinished = r)), // see end of updateDOM
+ skipTransition: () => {
+ skipTransition = true;
+ },
+ };
}
- try {
- await finished;
- } finally {
- // skip this for the moment as it tends to stop fallback animations
- // document.documentElement.removeAttribute('data-astro-transition');
+
+ viewTransition.ready.then(async () => {
await runScripts();
onPageLoad();
announce();
- }
+ });
+ viewTransition.finished.then(() => {
+ document.documentElement.removeAttribute(DIRECTION_ATTR);
+ document.documentElement.removeAttribute(OLD_NEW_ATTR);
+ });
+ await viewTransition.ready;
}
let navigateOnServerWarned = false;
-export function navigate(href: string, options?: Options) {
+export async function navigate(href: string, options?: Options) {
if (inBrowser === false) {
if (!navigateOnServerWarned) {
// instantiate an error for the stacktrace to show to user.
@@ -461,17 +576,7 @@ export function navigate(href: string, options?: Options) {
location.href = href;
return;
}
- const toLocation = new URL(href, location.href);
- // We do not have page transitions on navigations to the same page (intra-page navigation)
- // *unless* they are form posts which have side-effects and so need to happen
- // but we want to handle prevent reload on navigation to the same page
- // Same page means same origin, path and query params (but maybe different hash)
- if (location.origin === toLocation.origin && samePage(toLocation) && !options?.formData) {
- moveToLocation(toLocation, options?.history === 'replace', true);
- } else {
- // different origin will be detected by fetch
- transition('forward', toLocation, options ?? {});
- }
+ await transition('forward', originalLocation, new URL(href, location.href), options ?? {});
}
function onPopState(ev: PopStateEvent) {
@@ -479,10 +584,6 @@ function onPopState(ev: PopStateEvent) {
// The current page doesn't have View Transitions enabled
// but the page we navigate to does (because it set the state).
// Do a full page refresh to reload the client-side router from the new page.
- // Scroll restauration will then happen during the reload when the router's code is re-executed
- if (history.scrollRestoration) {
- history.scrollRestoration = 'manual';
- }
location.reload();
return;
}
@@ -492,28 +593,13 @@ function onPopState(ev: PopStateEvent) {
// Just ignore stateless entries.
// The browser will handle navigation fine without our help
if (ev.state === null) {
- if (history.scrollRestoration) {
- history.scrollRestoration = 'auto';
- }
return;
}
-
- // With the default "auto", the browser will jump to the old scroll position
- // before the ViewTransition is complete.
- if (history.scrollRestoration) {
- history.scrollRestoration = 'manual';
- }
-
const state: State = history.state;
- if (state.intraPage) {
- // this is non transition intra-page scrolling
- scrollTo(state.scrollX, state.scrollY);
- } else {
- const nextIndex = state.index;
- const direction: Direction = nextIndex > currentHistoryIndex ? 'forward' : 'back';
- currentHistoryIndex = nextIndex;
- transition(direction, new URL(location.href), {}, state);
- }
+ const nextIndex = state.index;
+ const direction: Direction = nextIndex > currentHistoryIndex ? 'forward' : 'back';
+ currentHistoryIndex = nextIndex;
+ transition(direction, originalLocation, new URL(location.href), {}, state);
}
// There's not a good way to record scroll position before a back button.
@@ -522,8 +608,10 @@ const onScroll = () => {
updateScrollPosition({ scrollX, scrollY });
};
+// initialization
if (inBrowser) {
if (supportsViewTransitions || getFallback() !== 'none') {
+ originalLocation = new URL(location.href);
addEventListener('popstate', onPopState);
addEventListener('load', onPageLoad);
if ('onscrollend' in window) addEventListener('scrollend', onScroll);
diff --git a/packages/astro/src/transitions/types.ts b/packages/astro/src/transitions/types.ts
new file mode 100644
index 000000000..0e70825e5
--- /dev/null
+++ b/packages/astro/src/transitions/types.ts
@@ -0,0 +1,10 @@
+export type Fallback = 'none' | 'animate' | 'swap';
+export type Direction = 'forward' | 'back';
+export type NavigationTypeString = 'push' | 'replace' | 'traverse';
+export type Options = {
+ history?: 'auto' | 'push' | 'replace';
+ info?: any;
+ state?: any;
+ formData?: FormData;
+ sourceElement?: Element; // more than HTMLElement, e.g. SVGAElement
+};
diff --git a/packages/astro/src/transitions/vite-plugin-transitions.ts b/packages/astro/src/transitions/vite-plugin-transitions.ts
index cd5b0e616..a3d68ade6 100644
--- a/packages/astro/src/transitions/vite-plugin-transitions.ts
+++ b/packages/astro/src/transitions/vite-plugin-transitions.ts
@@ -27,7 +27,14 @@ export default function astroTransitions({ settings }: { settings: AstroSettings
}
if (id === resolvedVirtualClientModuleId) {
return `
- export * from "astro/virtual-modules/transitions-router.js";
+ export { navigate, supportsViewTransitions, transitionEnabledOnThisPage } from "astro/virtual-modules/transitions-router.js";
+ export * from "astro/virtual-modules/transitions-types.js";
+ export {
+ TRANSITION_BEFORE_PREPARATION, isTransitionBeforePreparationEvent, TransitionBeforePreparationEvent,
+ TRANSITION_AFTER_PREPARATION,
+ TRANSITION_BEFORE_SWAP, isTransitionBeforeSwapEvent, TransitionBeforeSwapEvent,
+ TRANSITION_AFTER_SWAP, TRANSITION_PAGE_LOAD
+ } from "astro/virtual-modules/transitions-events.js";
`;
}
},
diff --git a/packages/astro/src/virtual-modules/transitions-events.ts b/packages/astro/src/virtual-modules/transitions-events.ts
new file mode 100644
index 000000000..35ecaf64f
--- /dev/null
+++ b/packages/astro/src/virtual-modules/transitions-events.ts
@@ -0,0 +1 @@
+export * from '../transitions/events.js';
diff --git a/packages/astro/src/virtual-modules/transitions-types.ts b/packages/astro/src/virtual-modules/transitions-types.ts
new file mode 100644
index 000000000..66dfb1d0e
--- /dev/null
+++ b/packages/astro/src/virtual-modules/transitions-types.ts
@@ -0,0 +1 @@
+export * from '../transitions/types.js';
diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts
index 0863ad1b4..f87c4e147 100644
--- a/packages/astro/src/vite-plugin-astro-server/route.ts
+++ b/packages/astro/src/vite-plugin-astro-server/route.ts
@@ -215,6 +215,7 @@ export async function handleRoute({
segments: [],
type: 'fallback',
route: '',
+ fallbackRoutes: [],
};
renderContext = await createRenderContext({
request,
@@ -222,6 +223,9 @@ export async function handleRoute({
env,
mod,
route,
+ locales: manifest.i18n?.locales,
+ routingStrategy: manifest.i18n?.routingStrategy,
+ defaultLocale: manifest.i18n?.defaultLocale,
});
} else {
return handle404Response(origin, incomingRequest, incomingResponse);
@@ -278,7 +282,9 @@ export async function handleRoute({
route: options.route,
mod,
env,
- locales: i18n ? i18n.locales : undefined,
+ locales: i18n?.locales,
+ routingStrategy: i18n?.routingStrategy,
+ defaultLocale: i18n?.defaultLocale,
});
}