import { getTopFrame, getNamedFrame } from './run.ts' type NavigationState = { target: string | undefined src: string resetScroll: boolean $rmx: true } type SourceElementNavigateEvent = NavigateEvent & { sourceElement?: Element | null } /** * Options for client-side frame-aware navigation. */ export type NavigationOptions = { src?: string target?: string history?: 'push' | 'replace' resetScroll?: boolean } /** * Performs a Navigation API transition understood by Remix frame runtime state. * * @param href Destination URL. * @param options Navigation options. */ export async function navigate(href: string, options?: NavigationOptions) { let state = { target: options?.target, src: options?.src ?? href, resetScroll: options?.resetScroll !== false, $rmx: true, } satisfies NavigationState let transition = window.navigation.navigate(href, { state, history: options?.history }) await transition.finished } /** * Starts listening for Navigation API transitions and routes them through frame reloads. * * @param signal Abort signal used to remove the listener. * @returns void */ export function startNavigationListener(signal: AbortSignal) { return startNavigationListenerImpl(signal, { getTopFrame, getNamedFrame }) } // Internal version used by unit tests so we can inject stub frames export function startNavigationListenerImpl( signal: AbortSignal, options: { getTopFrame: typeof getTopFrame getNamedFrame: typeof getNamedFrame }, ) { let navigation = window.navigation navigation.updateCurrentEntry({ state: { target: undefined, src: window.location.href, resetScroll: true, $rmx: true }, }) navigation.addEventListener( 'navigate', (event) => { // Safari seems to incorrectly set canIntercept to true for sub-domain navigations, so // we do a host check ourselves/. The spec is clear that a different host should prevent // interception so this is likely a bug in Safari: // https://html.spec.whatwg.org/multipage/nav-history-apis.html#can-have-its-url-rewritten if (!event.canIntercept || isCrossOriginDestination(event)) return let state = getRuntimeNavigationState(event) if (!state) return let topFrame = options.getTopFrame() let namedFrame = state.target ? options.getNamedFrame(state.target) : undefined let frame = namedFrame ?? topFrame event.intercept({ async handler() { if (event.navigationType !== 'traverse') { navigation.updateCurrentEntry({ state }) } frame.src = frame === topFrame ? event.destination.url : state.src await frame.reload() let isNewEntry = event.navigationType === 'push' || event.navigationType === 'replace' if (state.resetScroll && isNewEntry) { window.scrollTo(0, 0) } }, }) }, { signal }, ) } function isRuntimeNavigation(info: unknown): info is NavigationState { return typeof info === 'object' && info != null && '$rmx' in info } function isCrossOriginDestination(event: NavigateEvent): boolean { let destination = new URL(event.destination.url) return destination.origin !== window.location.origin } function getRuntimeNavigationState(event: NavigateEvent): NavigationState | undefined { if (event.navigationType === 'traverse') { return getTraverseNavigationState(event) } let sourceState = getSourceElementNavigationState(event) if (sourceState) return sourceState let destinationState = event.destination.getState() if (isRuntimeNavigation(destinationState)) return destinationState } function getTraverseNavigationState(event: NavigateEvent): NavigationState | undefined { let destinationState = event.destination.getState() if (isRuntimeNavigation(destinationState)) { return destinationState } // Safari returns `null` for destination.getState(), even though its in the // navigation.entries(), so we do its job for it and look it up. let navigation = window.navigation let matchingEntry = navigation.entries().find((entry) => entry.key === event.destination.key) if (matchingEntry) { let state = matchingEntry.getState() if (isRuntimeNavigation(state)) { return state } } return undefined } function getSourceElementNavigationState(event: NavigateEvent): NavigationState | undefined { let sourceEvent = event as SourceElementNavigateEvent let sourceElement = sourceEvent.sourceElement if (!(sourceElement instanceof Element)) return let linkElement = sourceElement.closest('a, area') if (!(linkElement instanceof Element)) return if (linkElement.hasAttribute('rmx-document')) return if (linkElement.hasAttribute('download')) return return { target: linkElement.getAttribute('rmx-target') ?? undefined, src: linkElement.getAttribute('rmx-src') ?? event.destination.url, resetScroll: linkElement.getAttribute('rmx-reset-scroll') !== 'false', $rmx: true, } satisfies NavigationState }