import type { PropsWithChildren } from 'react' import React, { useEffect, useState } from 'react' import { useRouter } from 'next/router' import { useAdmin } from '../use-admin' import { useNavigation } from '../router' const ADMIN_BASE_PATH = '/admin' export function Router({ children }: PropsWithChildren) { const ctx = useAdmin() const appBasePath = useAppBaseRoute() const router = useRouter() const [isMounted, setIsMounted] = useState(false) const { navigate } = useNavigation() // Only fires when the app pushes entries to the iframe history directly // (e.g. `next/link`); forwards the navigation to the Admin Shell to keep // the top window in sync. useEffect(() => { router.beforePopState(({ as }) => { const raccoonUrl = ADMIN_BASE_PATH + as if (ctx.path && raccoonUrl !== ctx.path) { navigate(as) return false } return true }) }, [router, ctx.path]) // Routes that aren't declared on the routes property shouldn't be available here. useEffect(() => { const { prodUrl } = ctx // If the route is not internal (it's the base path), we can mount the app. // This is also a backward compatible check, since the path is only available // apps using the most recent version of the @vtex/raccoon-next package. if (!ctx.path) { console.warn( `${prodUrl} is using an outdated version of @vtex/raccoon-next. Please update the package to the latest version.`, ) setIsMounted(true) return } /** * Remaining path is the path that comes after the base path * * @example * base path relative to the admin -> `/admin/rocket` * current path relative to the admin -> `/admin/rocket/nextjs-internal-route` * remaining path which should map back to the next.js app -> `/nextjs-internal-route` */ const remainingPath = ctx.path?.replace(ctx.basePath, '') /** * Check to prevent URL from being malformed with double slashes. * * If the app base path is `/` and the remaining path starts with `/`, * we should concat the app base path with the remaining path. * * If the app base path is `/` and the remaining path doesn't start with `/`, * but with a query string (?) or a hash (#), we should keep the remaining path as is. * * This check is only required for the app base path since query * strings or hash characters from the remaining path won't malform the URL, as it * isn't concatenated further down the line. */ const wouldMalformUrl = appBasePath === '/' && remainingPath.startsWith('/') /** * The safe app base path is the app base path that won't malform the URL * when concatenated with the remaining path. */ const safeAppBasePath = wouldMalformUrl ? '' : appBasePath /** * The internal path is the path that the app should be mounted on */ const internalPath = safeAppBasePath + remainingPath || '/' const ioAndNextjsRoutingMatch = internalPath === router.asPath if (!ioAndNextjsRoutingMatch) { // `replace` instead of `push`: the top window history (managed by the // Admin Shell) is the single source of truth, so the iframe must not // add its own entries to the browser's joint session history. router.replace(internalPath) } if (ioAndNextjsRoutingMatch) { // If the internal path matches the current path, we can mount the app setIsMounted(true) } }, [ctx.path, router.asPath]) if (!isMounted) { return <> } return <>{children} } /** * Gets the base route from a Next.js app * * Returns '/nextjs-base-route' if app entry point is declared as https://myapp.vtex.app/nextjs-base-route * * Returns '/' if app entry point is declared as https://myapp.vtex.com */ export function useAppBaseRoute() { const { devUrl, prodUrl, production } = useAdmin() const [devPathname, prodPathname] = [devUrl, prodUrl].map((url) => { if (url) { try { const urlObj = new URL(url) return urlObj.pathname } catch (error) { console.error(error) return '/' } } return '/' }) if (devPathname !== prodPathname) { // We shouldn't necessarily throw an error here, since the app might be in development console.warn( `The devUrl and prodUrl pathnames are different. Development pathname: ${devPathname}, production pathname: ${prodPathname}. Ensure that the pathnames for both environments are the same to guarantee consistency`, ) } // Get the app base path based from the corresponding environment const appBasePath = production ? prodPathname : devPathname return appBasePath }