import { cloneDeep, isEqual } from 'es-toolkit' import { get, set } from 'es-toolkit/compat' import { progress } from '.' import { config } from './config' import { eventHandler } from './eventHandler' import { fireBeforeEvent, fireClientVisitEvent, fireFlashEvent } from './events' import { history } from './history' import { InitialVisit } from './initialVisit' import { setPathPreservingIdentity, stripTopLevelUndefined } from './objectUtils' import { page as currentPage } from './page' import { polls } from './polls' import { prefetchedRequests } from './prefetched' import Queue from './queue' import { Request } from './request' import { RequestParams } from './requestParams' import { RequestStream } from './requestStream' import { Scroll } from './scroll' import { ActiveVisit, ClientSideVisitOptions, Component, FlashData, GlobalEvent, GlobalEventNames, GlobalEventResult, InFlightPrefetch, Method, OptimisticCallback, Page, PageFlashData, PageProps, PendingVisit, PollOptions, PrefetchedResponse, PrefetchOptions, ReloadOptions, RequestPayload, RouterInitParams, UrlMethodPair, Visit, VisitCallbacks, VisitHelperOptions, VisitOptions, } from './types' import { uid } from './uid' import { hrefToUrl, isSameUrlWithoutHash, isSameUrlWithoutQueryOrHash, isUrlMethodPair, transformUrlAndData, } from './url' const noop = () => {} export class Router { protected syncRequestStream = new RequestStream({ maxConcurrent: 1, interruptible: true, }) protected asyncRequestStream = new RequestStream({ maxConcurrent: Infinity, interruptible: false, }) protected clientVisitQueue = new Queue>() protected pendingOptimisticCallback: OptimisticCallback | undefined = undefined public init({ initialPage, resolveComponent, swapComponent, onFlash, }: RouterInitParams): void { currentPage.init({ initialPage, resolveComponent, swapComponent, onFlash, }) InitialVisit.handle() eventHandler.init() eventHandler.on('missingHistoryItem', () => { if (typeof window !== 'undefined') { this.visit(window.location.href, { preserveState: true, preserveScroll: true, replace: true }) } }) eventHandler.on('loadDeferredProps', (deferredProps: Page['deferredProps']) => { this.loadDeferredProps(deferredProps) }) eventHandler.on('historyQuotaExceeded', (url) => { window.location.href = url }) } public optimistic(callback: OptimisticCallback): this { this.pendingOptimisticCallback = callback as OptimisticCallback return this } public get( url: URL | string | UrlMethodPair, data: T = {} as T, options: VisitHelperOptions = {}, ): void { return this.visit(url, { ...options, method: 'get', data }) } public post( url: URL | string | UrlMethodPair, data: T = {} as T, options: VisitHelperOptions = {}, ): void { return this.visit(url, { preserveState: true, ...options, method: 'post', data }) } public put( url: URL | string | UrlMethodPair, data: T = {} as T, options: VisitHelperOptions = {}, ): void { return this.visit(url, { preserveState: true, ...options, method: 'put', data }) } public patch( url: URL | string | UrlMethodPair, data: T = {} as T, options: VisitHelperOptions = {}, ): void { return this.visit(url, { preserveState: true, ...options, method: 'patch', data }) } public delete( url: URL | string | UrlMethodPair, options: Omit, 'method'> = {}, ): void { return this.visit(url, { preserveState: true, ...options, method: 'delete' }) } public reload(options: ReloadOptions = {}): void { return this.doReload(options) } protected doReload( options: ReloadOptions & { deferredProps?: boolean poll?: boolean } = {}, ): void { if (typeof window === 'undefined') { return } return this.visit(window.location.href, { ...options, preserveScroll: true, preserveState: true, async: true, headers: { ...(options.headers || {}), 'Cache-Control': 'no-cache', }, }) } public remember(data: unknown, key = 'default'): void { history.remember(data, key) } public restore(key = 'default'): T | undefined { return history.restore(key) as T | undefined } public on( type: TEventName, callback: (event: GlobalEvent) => GlobalEventResult, ): VoidFunction { if (typeof window === 'undefined') { return () => {} } return eventHandler.onGlobalEvent(type, callback) } public once( type: TEventName, callback: (event: GlobalEvent) => GlobalEventResult, ): VoidFunction { if (typeof window === 'undefined') { return () => {} } const remove = this.on(type, (event) => { remove() return callback(event) }) return remove } public hasPendingOptimistic(): boolean { return this.asyncRequestStream.hasPendingOptimistic() } public get activePolls(): number { return polls.count } public cancelAll({ async = true, prefetch = true, sync = true } = {}): void { if (async) { this.asyncRequestStream.cancelInFlight({ prefetch }) } if (sync) { this.syncRequestStream.cancelInFlight() } } public poll(interval: number, requestOptions: ReloadOptions | (() => ReloadOptions) = {}, options: PollOptions = {}) { return polls.add( interval, ({ onStart, onFinish }) => { const resolved = typeof requestOptions === 'function' ? requestOptions() : requestOptions this.doReload({ poll: true, preserveErrors: true, ...resolved, onCancelToken: (token) => { onStart(token.cancel) resolved.onCancelToken?.(token) }, onFinish: (visit) => { onFinish() resolved.onFinish?.(visit) }, }) }, { autoStart: options.autoStart ?? true, background: options.background, keepAlive: options.keepAlive ?? false, mode: options.mode, }, ) } public visit( href: string | URL | UrlMethodPair, options: VisitOptions = {}, ): void { options.optimistic = options.optimistic ?? this.pendingOptimisticCallback this.pendingOptimisticCallback = undefined if (options.optimistic) { options.async = options.async ?? true } const visit: PendingVisit = this.getPendingVisit(href, { ...options, showProgress: options.showProgress ?? (!options.async || !!options.optimistic), } as VisitOptions) const events = this.getVisitEvents(options as VisitOptions) // If either of these return false, we don't want to continue if (events.onBefore(visit) === false || !fireBeforeEvent(visit)) { return } const currentPageUrl = hrefToUrl(currentPage.get().url) const isPartialReload = visit.only.length > 0 || visit.except.length > 0 || visit.reset.length > 0 // For partial reloads, only compare the base URL (origin + pathname) to allow // concurrent requests with different query params to the same page const isSamePage = isPartialReload ? isSameUrlWithoutQueryOrHash(visit.url, currentPageUrl) : isSameUrlWithoutHash(visit.url, currentPageUrl) if (!isSamePage) { // Cancel in-flight requests aimed at the page we're navigating away from // (deferred props, partial reloads, plain reloads), but leave prefetches, // optimistic requests, and background async visits to other pages untouched this.asyncRequestStream.cancelInFlight( (request) => !request.isPrefetch() && !request.isOptimistic() && isSameUrlWithoutQueryOrHash(request.getUrl(), currentPageUrl), ) } // Interrupt in-flight requests before taking the optimistic snapshot // so that any previous optimistic state is restored first if (!visit.async) { this.syncRequestStream.interruptInFlight() } let optimisticId: number | null = null if (options.optimistic) { optimisticId = currentPage.nextOptimisticId() this.applyOptimisticUpdate(options.optimistic, events, optimisticId) } if (!currentPage.isCleared() && !visit.preserveUrl) { // Save scroll regions for the current page Scroll.save() } const requestParams: PendingVisit & VisitCallbacks = { ...visit, ...events, } const sendRequest = () => { const prefetched = prefetchedRequests.get(requestParams) if (prefetched) { progress.reveal(prefetched.inFlight) prefetchedRequests.use(prefetched, requestParams) } else { progress.reveal(true) const requestStream = visit.async ? this.asyncRequestStream : this.syncRequestStream requestStream.send(Request.create(requestParams, currentPage.get(), { optimisticId })) } } if (Array.isArray(visit.component)) { console.error( `The "component" prop received an array of components (${visit.component.join(', ')}), but only a single component string is supported for instant visits. Pass an explicit component name instead.`, ) visit.component = null } if (visit.component) { history.processQueue().then(() => { this.performInstantSwap(visit).then(() => { requestParams.preserveScroll = true requestParams.preserveState = true requestParams.replace = true requestParams.viewTransition = false sendRequest() }) }) } else { sendRequest() } } public getCached( href: string | URL | UrlMethodPair, options: VisitOptions = {}, ): InFlightPrefetch | PrefetchedResponse | null { return prefetchedRequests.findCached(this.getPrefetchParams(href, options)) } public flush(href: string | URL | UrlMethodPair, options: VisitOptions = {}): void { prefetchedRequests.remove(this.getPrefetchParams(href, options)) } public flushAll(): void { prefetchedRequests.removeAll() } public flushByCacheTags(tags: string | string[]): void { prefetchedRequests.removeByTags(Array.isArray(tags) ? tags : [tags]) } public getPrefetching( href: string | URL | UrlMethodPair, options: VisitOptions = {}, ): InFlightPrefetch | PrefetchedResponse | null { return prefetchedRequests.findInFlight(this.getPrefetchParams(href, options)) } public prefetch( href: string | URL | UrlMethodPair, options: VisitOptions = {}, prefetchOptions: Partial = {}, ) { const method: Method = options.method ?? (isUrlMethodPair(href) ? href.method : 'get') if (method !== 'get') { throw new Error('Prefetch requests must use the GET method') } const visit: PendingVisit = this.getPendingVisit(href, { ...options, async: true, showProgress: false, prefetch: true, viewTransition: false, }) const visitUrl = visit.url.origin + visit.url.pathname + visit.url.search const currentUrl = window.location.origin + window.location.pathname + window.location.search if (visitUrl === currentUrl) { // Don't prefetch the current page, you're already on it return } const events = this.getVisitEvents(options) // If either of these return false, we don't want to continue if (events.onBefore(visit) === false || !fireBeforeEvent(visit)) { return } progress.hide() this.asyncRequestStream.interruptInFlight() const requestParams: PendingVisit & VisitCallbacks = { ...visit, ...events, } const ensureCurrentPageIsSet = (): Promise => { return new Promise((resolve) => { const checkIfPageIsDefined = () => { if (currentPage.get()) { resolve() } else { setTimeout(checkIfPageIsDefined, 50) } } checkIfPageIsDefined() }) } ensureCurrentPageIsSet().then(() => { prefetchedRequests.add( requestParams, (params) => { this.asyncRequestStream.send(Request.create(params, currentPage.get())) }, { cacheFor: config.get('prefetch.cacheFor'), cacheTags: [], ...prefetchOptions, }, ) }) } public clearHistory(): void { history.clear() prefetchedRequests.removeAll() } public decryptHistory(): Promise { return history.decrypt() } public resolveComponent(component: string, page?: Page): Promise { return currentPage.resolve(component, page) } public replace(params: ClientSideVisitOptions): void { this.clientVisit(params, { replace: true }) } public replaceProp( name: string, value: unknown | ((oldValue: unknown, props: TProps) => unknown), options?: Pick, ): void { this.replace({ preserveScroll: true, preserveState: true, props(currentProps) { const newValue = typeof value === 'function' ? value(get(currentProps, name), currentProps) : value return setPathPreservingIdentity(currentProps, name, newValue) }, ...(options || {}), }) } public appendToProp( name: string, value: unknown | unknown[] | ((oldValue: unknown, props: TProps) => unknown | unknown[]), options?: Pick, ): void { this.replaceProp( name, (currentValue: unknown, currentProps: TProps) => { const newValue = typeof value === 'function' ? value(currentValue, currentProps) : value if (!Array.isArray(currentValue)) { currentValue = currentValue !== undefined ? [currentValue] : [] } return [...(currentValue as unknown[]), newValue] }, options, ) } public prependToProp( name: string, value: unknown | unknown[] | ((oldValue: unknown, props: TProps) => unknown | unknown[]), options?: Pick, ): void { this.replaceProp( name, (currentValue: unknown, currentProps: TProps) => { const newValue = typeof value === 'function' ? value(currentValue, currentProps) : value if (!Array.isArray(currentValue)) { currentValue = currentValue !== undefined ? [currentValue] : [] } return [newValue, ...(currentValue as unknown[])] }, options, ) } public push(params: ClientSideVisitOptions): void { this.clientVisit(params) } public flash( keyOrData: string | ((flash: FlashData) => TFlash) | TFlash, value?: unknown, ): void { const current = currentPage.get().flash let flash: PageFlashData if (typeof keyOrData === 'function') { flash = keyOrData(current) } else if (typeof keyOrData === 'string') { flash = { ...current, [keyOrData]: value } } else if (keyOrData && Object.keys(keyOrData).length) { flash = { ...current, ...keyOrData } } else { return } currentPage.setFlash(flash) if (Object.keys(flash).length) { fireFlashEvent(flash) } } protected clientVisit( params: ClientSideVisitOptions, { replace = false }: { replace?: boolean } = {}, ): void { this.clientVisitQueue.add(() => this.performClientVisit(params, { replace })) } protected performClientVisit( params: ClientSideVisitOptions, { replace = false }: { replace?: boolean } = {}, ): Promise { const current = currentPage.get() const onceProps = typeof params.props === 'function' ? Object.fromEntries( Object.values(current.onceProps ?? {}).map((onceProp) => [ onceProp.prop, get(current.props, onceProp.prop), ]), ) : {} const props = typeof params.props === 'function' ? params.props(current.props as TProps, onceProps as Partial) : (params.props ?? current.props) const flash = typeof params.flash === 'function' ? params.flash(current.flash) : params.flash const { viewTransition, onError, onFinish, onFlash, onSuccess, ...pageParams } = params const page = { ...current, ...pageParams, flash: flash ?? {}, props: props as Page['props'], } const preserveScroll = RequestParams.resolvePreserveOption(params.preserveScroll ?? false, page) const preserveState = RequestParams.resolvePreserveOption(params.preserveState ?? false, page) const visitId = this.createVisitId() return currentPage .set(page, { replace, preserveScroll, preserveState, viewTransition, visitId, }) .then(() => { fireClientVisitEvent(currentPage.get(), { replace, visitId }) const currentFlash = currentPage.get().flash if (Object.keys(currentFlash).length > 0) { fireFlashEvent(currentFlash) onFlash?.(currentFlash) } const errors = currentPage.get().props.errors || {} if (Object.keys(errors).length === 0) { onSuccess?.(currentPage.get()) return } const scopedErrors = params.errorBag ? errors[params.errorBag || ''] || {} : errors onError?.(scopedErrors) }) .finally(() => onFinish?.(params)) } protected performInstantSwap(visit: PendingVisit): Promise { const current = currentPage.get() const sharedProps = Object.fromEntries( (current.sharedProps ?? []).filter((key) => key in current.props).map((key) => [key, current.props[key]]), ) const resolvedPageProps = typeof visit.pageProps === 'function' ? visit.pageProps(cloneDeep(current.props), cloneDeep(sharedProps)) : visit.pageProps const intermediateProps = resolvedPageProps !== null ? { ...resolvedPageProps } : { ...sharedProps } const onceProps = this.preserveOncePropsOnInstantVisit(current, intermediateProps) const intermediatePage: Page = { component: visit.component!, url: visit.url.pathname + visit.url.search + visit.url.hash, version: current.version, props: { ...intermediateProps, errors: {}, }, flash: {}, rescuedProps: [], clearHistory: false, encryptHistory: current.encryptHistory, sharedProps: current.sharedProps, onceProps, rememberedState: {}, } return currentPage.set(intermediatePage, { replace: visit.replace, preserveScroll: RequestParams.resolvePreserveOption(visit.preserveScroll, intermediatePage), preserveState: false, viewTransition: visit.viewTransition, visitId: visit.id, }) } /** * Once props are remembered client-side, so the placeholder page must preserve their values * and registry. Otherwise the swap discards the value, and an in-flight prefetch that already * claimed the prop resolves with nothing to restore it from. */ protected preserveOncePropsOnInstantVisit(current: Page, props: PageProps): Page['onceProps'] { const onceProps: NonNullable = {} Object.entries(current.onceProps ?? {}).forEach(([key, onceProp]) => { if (get(props, onceProp.prop) !== undefined) { // The visit provided its own value, so we can't claim to remember the once prop return } const currentValue = get(current.props, onceProp.prop) if (currentValue === undefined) { return } set(props, onceProp.prop, currentValue) onceProps[key] = onceProp }) return onceProps } protected getPrefetchParams(href: string | URL | UrlMethodPair, options: VisitOptions): ActiveVisit { return { ...this.getPendingVisit(href, { ...options, async: true, showProgress: false, prefetch: true, viewTransition: false, }), ...this.getVisitEvents(options), } } protected createVisitId(): string { return uid() } protected getPendingVisit(href: string | URL | UrlMethodPair, options: VisitOptions): PendingVisit { if (isUrlMethodPair(href)) { const urlMethodPair = href href = urlMethodPair.url options.method = options.method ?? urlMethodPair.method } const defaultVisitOptionsCallback = config.get('visitOptions') const configuredOptions = defaultVisitOptionsCallback ? defaultVisitOptionsCallback(href.toString(), cloneDeep(options)) || {} : {} const mergedOptions: Visit = { method: 'get', data: {}, replace: false, preserveScroll: false, preserveState: false, only: [], except: [], headers: {}, errorBag: '', forceFormData: false, queryStringArrayFormat: 'brackets', async: false, showProgress: true, fresh: false, reset: [], preserveUrl: false, preserveErrors: false, prefetch: false, invalidateCacheTags: [], viewTransition: false, component: null, pageProps: null, cached: false, ...stripTopLevelUndefined(options), ...stripTopLevelUndefined(configuredOptions), } const [url, _data] = transformUrlAndData( href, mergedOptions.data, mergedOptions.method, mergedOptions.forceFormData, mergedOptions.queryStringArrayFormat, ) const visit = { id: this.createVisitId(), cancelled: false, completed: false, interrupted: false, ...mergedOptions, url, data: _data, } if (visit.prefetch) { visit.headers['Purpose'] = 'prefetch' } return visit } protected getVisitEvents(options: VisitOptions): VisitCallbacks { return { onCancelToken: options.onCancelToken || noop, onBefore: options.onBefore || noop, onBeforeUpdate: options.onBeforeUpdate || noop, onStart: options.onStart || noop, onProgress: options.onProgress || noop, onFinish: options.onFinish || noop, onCancel: options.onCancel || noop, onSuccess: options.onSuccess || noop, onError: options.onError || noop, onHttpException: options.onHttpException || noop, onNetworkError: options.onNetworkError || noop, onFlash: options.onFlash || noop, onPrefetched: options.onPrefetched || noop, onPrefetching: options.onPrefetching || noop, } } protected applyOptimisticUpdate(optimistic: OptimisticCallback, events: VisitCallbacks, id: number): void { const currentProps = currentPage.get().props const optimisticProps = optimistic(cloneDeep(currentProps)) if (!optimisticProps) { return } const changedKeys: string[] = [] for (const key of Object.keys(optimisticProps)) { if (!isEqual(currentProps[key], optimisticProps[key])) { changedKeys.push(key) } } if (changedKeys.length === 0) { return } const component = currentPage.get().component for (const key of changedKeys) { currentPage.setBaseline(key, cloneDeep(currentProps[key])) } currentPage.registerOptimistic(id, optimistic) currentPage.setPropsQuietly({ ...currentProps, ...optimisticProps }) let shouldRestore = true const originalOnSuccess = events.onSuccess events.onSuccess = (page) => { shouldRestore = false currentPage.markOptimisticConfirmed(id) return originalOnSuccess(page) } const originalOnFinish = events.onFinish events.onFinish = (visit) => { currentPage.unregisterOptimistic(id) if (shouldRestore && currentPage.get().component === component) { const replayedProps = currentPage.replayOptimistics() if (Object.keys(replayedProps).length > 0) { currentPage.setPropsQuietly({ ...currentPage.get().props, ...replayedProps }) } } if (currentPage.pendingOptimisticCount() === 0) { currentPage.clearOptimisticState() } return originalOnFinish(visit) } } protected loadDeferredProps(deferred: Page['deferredProps']): void { if (deferred) { Object.values(deferred).forEach((props) => { this.doReload({ only: props, deferredProps: true, preserveErrors: true }) }) } } }