diff --git a/packages/core/src/eventHandler.ts b/packages/core/src/eventHandler.ts index 75a543b05..43714dcef 100644 --- a/packages/core/src/eventHandler.ts +++ b/packages/core/src/eventHandler.ts @@ -3,10 +3,17 @@ import debounce from './debounce' import { fireNavigateEvent } from './events' import { history } from './history' import { router } from './index' +import { + layerClosing, + layerDismissedByRestore, + layersOf, + recordHistoryEntry, + restoreKeepsBase, + withAddressHash, +} from './layers' import { page as currentPage } from './page' import { Scroll } from './scroll' -import { GlobalEvent, GlobalEventNames, GlobalEventResult, InternalEvent } from './types' -import { hrefToUrl } from './url' +import { GlobalEvent, GlobalEventNames, GlobalEventResult, InternalEvent, LayerState } from './types' class EventHandler { protected internalListeners: { @@ -50,6 +57,7 @@ class EventHandler { } public onMissingHistoryItem() { + layerClosing.settleUnwind() // At this point, the user has probably cleared the state // Mark the current page as cleared so that we don't try to write anything to it. currentPage.clear() @@ -82,10 +90,10 @@ class EventHandler { const state = event.state || null if (state === null) { - const url = hrefToUrl(currentPage.get().url) - url.hash = window.location.hash - - history.replaceState({ ...currentPage.getWithoutFlashData(), url: url.href }) + // An entry the browser wrote itself, usually for an in-page anchor. It stands between the + // stack and the entry beneath it, so closing a layer has one more step to take back. + layerClosing.settleUnwind() + history.replaceState(withAddressHash(recordHistoryEntry(currentPage.getWithoutFlashData()), window.location.hash)) Scroll.reset() return @@ -103,28 +111,64 @@ class EventHandler { return } - // Cancel ongoing requests except prefetch requests - router.cancelAll({ prefetch: false }) + // A close's unwind steps back onto the page that is staying, so anything in flight for it + // belongs to the page coming back. A genuine back navigates away, and takes its requests. + const unwindingOntoTheSamePage = layerClosing.isUnwinding() && currentPage.isTheSame(data) - currentPage.setQuietly(data, { preserveState: false }).then(() => { - Scroll.restore(history.getScrollRegions()) - fireNavigateEvent(currentPage.get()) + if (!unwindingOntoTheSamePage) { + // Cancel ongoing requests except prefetch requests + router.cancelAll({ prefetch: false }) + } - const pendingDeferred: Record = {} - const pageProps = currentPage.get().props + if (layerClosing.unwindLandsItself()) { + layerClosing.settleUnwind() + return + } - for (const [group, props] of Object.entries(data.initialDeferredProps ?? data.deferredProps ?? {})) { - const missing = props.filter((prop) => get(pageProps, prop) === undefined) + if (data.component === '') { + layerClosing.settleUnwind() + router.visit(data.url, { replace: true, preserveScroll: true, preserveState: true }) + return + } - if (missing.length > 0) { - pendingDeferred[group] = missing + // Back over a single layer dismisses it, so it leaves like every other dismissal: marked, + // given its exit, taken off once the shell reports. + const dismissed = layerClosing.isUnwinding() ? undefined : layerDismissedByRestore(currentPage.get(), data) + const dismissal = + dismissed && !layerClosing.isClosing(dismissed.id) + ? layerClosing.close(dismissed.id, { absorbed: true }) + : Promise.resolve() + + // The restore lands on the page already on screen, so remounting it would throw its state away. + const landsOnThePageOnScreen = + unwindingOntoTheSamePage || !!dismissed || restoreKeepsBase(currentPage.get(), data) + + dismissal + .then(() => currentPage.setQuietly(data, { preserveState: landsOnThePageOnScreen })) + .then(() => { + layerClosing.settleUnwind() + Scroll.restore(history.getScrollRegions()) + fireNavigateEvent(currentPage.get()) + + for (const tier of [data, ...layersOf(data)]) { + const pendingDeferred: Record = {} + + for (const [group, props] of Object.entries(tier.initialDeferredProps ?? tier.deferredProps ?? {})) { + const missing = props.filter((prop) => get(tier.props, prop) === undefined) + + if (missing.length > 0) { + pendingDeferred[group] = missing + } + } + + if (Object.keys(pendingDeferred).length > 0) { + this.fireInternalEvent('loadDeferredProps', { + deferredProps: pendingDeferred, + layerId: (tier as LayerState).id, + }) + } } - } - - if (Object.keys(pendingDeferred).length > 0) { - this.fireInternalEvent('loadDeferredProps', pendingDeferred) - } - }) + }) }) .catch(() => { this.onMissingHistoryItem() diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 2f29ad76b..206c25d6e 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -1,3 +1,4 @@ +import { addressOf } from './layers' import { GlobalEventDetails, GlobalEventNames, GlobalEventTrigger } from './types' function fireEvent( @@ -32,7 +33,7 @@ export const fireBeforeUpdateEvent: GlobalEventTrigger<'beforeUpdate'> = (page) } export const fireNavigateEvent: GlobalEventTrigger<'navigate'> = (page, { cached = false, visitId } = {}) => { - return fireEvent('navigate', { detail: { page, cached, visitId } }) + return fireEvent('navigate', { detail: { page, url: addressOf(page), cached, visitId } }) } export const fireClientVisitEvent: GlobalEventTrigger<'clientVisit'> = (page, { replace, visitId }) => { @@ -48,7 +49,7 @@ export const fireStartEvent: GlobalEventTrigger<'start'> = (visit) => { } export const fireSuccessEvent: GlobalEventTrigger<'success'> = (page, { visitId } = {}) => { - return fireEvent('success', { detail: { page, visitId } }) + return fireEvent('success', { detail: { page, url: addressOf(page), visitId } }) } export const firePrefetchedEvent: GlobalEventTrigger<'prefetched'> = (response, visit) => { diff --git a/packages/core/src/head.ts b/packages/core/src/head.ts index 7cc03cc21..a9f124421 100644 --- a/packages/core/src/head.ts +++ b/packages/core/src/head.ts @@ -1,5 +1,6 @@ import debounce from './debounce' -import type { HeadManager, HeadManagerOnUpdateCallback, Page, ServerHeadOption } from './types' +import { topPageOf } from './layers' +import type { HeadManager, HeadManagerOnUpdateCallback, LayerState, Page, ServerHeadOption } from './types' const serverHeadProviderId = 'server' @@ -17,8 +18,11 @@ export function resolveServerHead(page: Page, serverHead?: ServerHeadOption): st return [] } + // The top layer's head wins, exactly as a in a layer does. + const headPage = topPageOf(page) + const elements = - typeof serverHead === 'function' ? serverHead(page) : page.props[serverHead === true ? 'head' : serverHead] + typeof serverHead === 'function' ? serverHead(headPage) : headPage.props[serverHead === true ? 'head' : serverHead] if (!Array.isArray(elements)) { return [] @@ -96,15 +100,18 @@ export default function createHeadManager( titleCallback: (title: string) => string, onUpdate: HeadManagerOnUpdateCallback, initialServerHead: Array = [], + stack: () => LayerState[] = () => [], ): HeadManager { const states: Record> = initialServerHead.length ? { [serverHeadProviderId]: initialServerHead } : {} + const providers: Record = {} let lastProviderId = 0 - function connect(): string { + function connect(layerId?: string): string { const id = (lastProviderId += 1) states[id] = [] + providers[id] = layerId return id.toString() } @@ -114,12 +121,14 @@ export default function createHeadManager( } delete states[id] + delete providers[id] commit() } - function reconnect(id: string): void { + function reconnect(id: string, layerId?: string): void { if (Object.keys(states).indexOf(id) === -1) { states[id] = [] + providers[id] = layerId } } @@ -144,8 +153,15 @@ export default function createHeadManager( function collect(): Array { const title = titleCallback('') const serverHead = states[serverHeadProviderId] || [] + + const open = stack() + // Ordered by the stack, so the top layer's title wins. A provider naming no layer ranks below + // every layer, which is where the base belongs. + const rankOf = (id: string): number => open.findIndex((layer) => layer.id === providers[id]) + const providerHead = Object.keys(states) .filter((id) => id !== serverHeadProviderId) + .sort((a, b) => rankOf(a) - rankOf(b)) .flatMap((id) => states[id]) const defaults: Record = { @@ -187,11 +203,11 @@ export default function createHeadManager( return { forceUpdate: commit, updateServerHead, - createProvider: function () { - const id = connect() + createProvider: function (layerId?: string) { + const id = connect(layerId) return { - reconnect: () => reconnect(id), + reconnect: () => reconnect(id, layerId), update: (elements) => update(id, elements), disconnect: () => disconnect(id), } diff --git a/packages/core/src/history.ts b/packages/core/src/history.ts index 9e311b445..c8ef890e9 100644 --- a/packages/core/src/history.ts +++ b/packages/core/src/history.ts @@ -1,6 +1,8 @@ -import { cloneDeep, isEqual } from 'es-toolkit' +import { isEqual } from 'es-toolkit' import { decryptHistory, encryptHistory, historySessionStorageKeys } from './encryption' import { eventHandler } from './eventHandler' +import { addressOf, encryptsHistory, layerClosing, mapLayers, promoteDeepestLayer } from './layers' +import { toStructuredCloneable } from './objectUtils' import { page as currentPage } from './page' import Queue from './queue' import { SessionStorage } from './sessionStorage' @@ -18,21 +20,34 @@ class History { // We need initialState for `restore` protected initialState: Partial | null = null - public remember(data: unknown, key: string): void { + public remember(data: unknown, key: string, layerId?: string): void { + if (isServer) { + return + } + + const current = currentPage.get().rememberedState ?? {} + this.replaceState({ ...currentPage.getWithoutFlashData(), - rememberedState: { - ...(currentPage.get()?.rememberedState ?? {}), - [key]: data, - }, + rememberedState: layerId + ? { ...current, [layerId]: { ...(current[layerId] ?? {}), [key]: data } } + : { ...current, [key]: data }, }) } - public restore(key: string): unknown { + public restore(key: string, layerId?: string): unknown { if (!isServer) { - return this.current[this.rememberedState]?.[key] !== undefined - ? this.current[this.rememberedState]?.[key] - : this.initialState?.[this.rememberedState]?.[key] + const current = this.current[this.rememberedState] as Record | undefined + const initial = this.initialState?.[this.rememberedState] as Record | undefined + + if (layerId) { + const stored = (current?.[layerId] ?? {}) as Record + const storedInitial = (initial?.[layerId] ?? {}) as Record + + return stored[key] !== undefined ? stored[key] : storedInitial[key] + } + + return current?.[key] !== undefined ? current?.[key] : initial?.[key] } } @@ -52,7 +67,7 @@ class History { return this.getPageData(page).then((data) => { // Defer history.pushState to the next event loop tick to prevent timing conflicts. // Ensure any previous history.replaceState completes before pushState is executed. - const doPush = () => this.doPushState({ page: data }, page.url).then(() => cb?.()) + const doPush = () => this.doPushState({ page: data }, addressOf(page)).then(() => cb?.()) if (isChromeIOS) { return new Promise((resolve) => { @@ -67,23 +82,22 @@ class History { protected clonePageProps(page: Page): Page { try { - structuredClone(page.props) + structuredClone(page) return page } catch { - // Props contain non-serializable data (e.g., Proxies, functions). - // Clone them to ensure they can be safely stored in browser history. - return { - ...page, - props: cloneDeep(page.props), - } + // Props contain non-serializable data (e.g., Proxies, functions), which + // the browser cannot store in history. Drop what it cannot carry. + return toStructuredCloneable(page) } } protected getPageData(page: Page): Promise { - const pageWithClonedProps = this.clonePageProps(page) + const open = layerClosing.withoutClosingLayers(page) + const entry = open.component === '' ? promoteDeepestLayer(open) : open + const pageWithClonedProps = this.clonePageProps(entry) return new Promise((resolve) => { - return page.encryptHistory ? encryptHistory(pageWithClonedProps).then(resolve) : resolve(pageWithClonedProps) + return encryptsHistory(entry) ? encryptHistory(pageWithClonedProps).then(resolve) : resolve(pageWithClonedProps) }) } @@ -117,20 +131,24 @@ class History { return pageData instanceof ArrayBuffer ? decryptHistory(pageData) : Promise.resolve(pageData) } - public saveScrollPositions(scrollRegions: ScrollRegion[]): void { + public saveScrollPositions(scrollRegions: ScrollRegion[], scrollRegionLayers?: (string | null)[]): void { queue.add(() => { return Promise.resolve().then(() => { if (!window.history.state?.page) { return } - if (isEqual(this.getScrollRegions(), scrollRegions)) { + if ( + isEqual(this.getScrollRegions(), scrollRegions) && + isEqual(this.getScrollRegionLayers() ?? [], scrollRegionLayers ?? []) + ) { return } return this.doReplaceState({ page: window.history.state.page, scrollRegions, + scrollRegionLayers, }) }) }) @@ -159,6 +177,10 @@ class History { return window.history.state?.scrollRegions || [] } + public getScrollRegionLayers(): (string | null)[] | undefined { + return window.history.state?.scrollRegionLayers + } + public getDocumentScrollPosition(): ScrollRegion { return window.history.state?.documentScrollPosition || { top: 0, left: 0 } } @@ -171,7 +193,12 @@ class History { // Exclude flash from the merge to prevent callers (like router.remember()) // from accidentally clearing flash data on the current page. - const { flash, ...pageWithoutFlash } = page + const live = currentPage.get().layers ?? [] + const { flash, ...pageWithoutFlash } = mapLayers(page, (layer) => ({ + ...layer, + flash: live.find((open) => open.id === layer.id)?.flash ?? {}, + })) + currentPage.merge(pageWithoutFlash) if (isServer) { @@ -189,7 +216,7 @@ class History { return this.getPageData(page).then((data) => { // Defer history.replaceState to the next event loop tick to prevent timing conflicts. // Ensure any previous history.pushState completes before replaceState is executed. - const doReplace = () => this.doReplaceState({ page: data }, page.url).then(() => cb?.()) + const doReplace = () => this.doReplaceState({ page: data }, addressOf(page)).then(() => cb?.()) if (isChromeIOS) { return new Promise((resolve) => { @@ -202,6 +229,12 @@ class History { }) } + // Steps back over the entries a closing stack stands on, behind the writes queued ahead of it: + // under encryption those are genuinely async, and stepping early would cross the wrong entry. + public back(entries: number): void { + queue.add(() => Promise.resolve().then(() => window.history.go(-entries))) + } + protected isHistoryThrottleError(error: unknown): error is Error & { name: 'SecurityError' } { return ( error instanceof Error && @@ -232,14 +265,19 @@ class History { data: { page: Page | ArrayBuffer scrollRegions?: ScrollRegion[] + scrollRegionLayers?: (string | null)[] documentScrollPosition?: ScrollRegion }, url?: string, ): Promise { return this.withThrottleProtection(() => { + const { scrollRegionLayers, ...state } = data + const carriedLayers = 'scrollRegionLayers' in data ? scrollRegionLayers : window.history.state?.scrollRegionLayers + window.history.replaceState( { - ...data, + ...state, + ...(carriedLayers !== undefined && { scrollRegionLayers: carriedLayers }), scrollRegions: data.scrollRegions ?? window.history.state?.scrollRegions, documentScrollPosition: data.documentScrollPosition ?? window.history.state?.documentScrollPosition, }, @@ -253,6 +291,7 @@ class History { data: { page: Page | ArrayBuffer scrollRegions?: ScrollRegion[] + scrollRegionLayers?: (string | null)[] documentScrollPosition?: ScrollRegion }, url: string, @@ -261,6 +300,13 @@ class History { try { window.history.pushState(data, '', url) } catch (error) { + if (this.isHistoryThrottleError(error)) { + console.error(error.message) + eventHandler.fireInternalEvent('historyEntryDropped', url) + + return + } + if (!this.isQuotaExceededError(error)) { throw error } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5c519c349..3409b5ea4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,9 +23,28 @@ export { type LayoutCallbackReturn, type LayoutDefinition, type LayoutPropsStore, + type LayoutSlot, + type SetLayoutProps, + emptyLayoutSlot, } from './layout' +export { createLayerApi, type LayerApi } from './router' +export { cancelLayer, layerDialogAttributes, lockScroll, observeExit, raiseLayer, type LayerExit } from './layerDialog' +export { + layerShellProps, + layerTransitionName, + layoutPageOf, + normalizeLoading, + resolveInitialPage, + topPageOf, + type LayerHandle, +} from './layers' export { shouldIntercept, shouldNavigate } from './navigationEvents' -export { isPathOrSubPath, partialReloadRequestsProp, partialReloadRequestsSomeProps } from './partialReload' +export { + isPathOrSubPath, + partialReloadFillsDeferred, + partialReloadRequestsProp, + partialReloadRequestsSomeProps, +} from './partialReload' export { progress, default as setupProgress } from './progress' export { FormComponentResetSymbol, resetFormFields } from './resetFormFields' export { buildSSRBody } from './ssrUtils' diff --git a/packages/core/src/infiniteScroll/data.ts b/packages/core/src/infiniteScroll/data.ts index c4f9f6001..a27bbad8f 100644 --- a/packages/core/src/infiniteScroll/data.ts +++ b/packages/core/src/infiniteScroll/data.ts @@ -1,4 +1,5 @@ import { ActiveVisit, router } from '../index' +import { tierOf } from '../layers' import { page as currentPage } from '../page' import { Page, PendingVisit, ReloadOptions, ScrollProp, UseInfiniteScrollDataManager } from '../types' @@ -18,6 +19,8 @@ export type InfiniteScrollPageIdentifier = string | number | null export type InfiniteScrollOnCompleteDetails = { page: InfiniteScrollPageIdentifier; completed: boolean } export const useInfiniteScrollData = (options: { + /** @internal */ + layerId?: string getPropName: () => string onBeforeUpdate: () => void onBeforePreviousRequest: () => void @@ -35,7 +38,7 @@ export const useInfiniteScrollData = (options: { onReset?: () => void }): UseInfiniteScrollDataManager => { const getScrollPropFromCurrentPage = (): ScrollProp => { - const scrollProp = currentPage.get().scrollProps?.[options.getPropName()] + const scrollProp = tierOf(currentPage.get(), options.layerId).scrollProps?.[options.getPropName()] if (scrollProp) { return scrollProp @@ -72,7 +75,7 @@ export const useInfiniteScrollData = (options: { if (typeof window !== 'undefined') { resetState() - const rememberedState = router.restore(getRememberKey()) as InfiniteScrollState | undefined + const rememberedState = router.restore(getRememberKey(), options.layerId) as InfiniteScrollState | undefined if ( rememberedState && @@ -124,6 +127,7 @@ export const useInfiniteScrollData = (options: { requestCount: state.requestCount, } as InfiniteScrollState, getRememberKey(), + options.layerId, ) } @@ -144,6 +148,7 @@ export const useInfiniteScrollData = (options: { ...reloadOptions, data: { ...(reloadOptions.data || {}), [getPageName()]: page }, only: [...(reloadOptions.only || []), options.getPropName()], + layerId: options.layerId, preserveUrl: true, // we handle URL updates manually via useInfiniteScrollQueryString() headers: { [MERGE_INTENT_HEADER]: side === 'previous' ? 'prepend' : 'append', diff --git a/packages/core/src/infiniteScroll/queryString.ts b/packages/core/src/infiniteScroll/queryString.ts index 3b523cccf..ce2b4f652 100644 --- a/packages/core/src/infiniteScroll/queryString.ts +++ b/packages/core/src/infiniteScroll/queryString.ts @@ -1,6 +1,7 @@ import { hrefToUrl, router, urlHasProtocol, urlToString } from '..' import debounce from '../debounce' import { getElementsInViewportFromCollection } from '../domUtils' +import { tierOf } from '../layers' import { page as currentPage } from './../page' import Queue from './../queue' import { getPageFromElement } from './elements' @@ -8,9 +9,9 @@ import { getPageFromElement } from './elements' // Shared queue among all instances to ensure URL updates are processed sequentially const queue = new Queue>() -let initialUrl: URL | null -let payloadUrl: URL | null -let initialUrlWasAbsolute: boolean | null = null +// The url each tier is accumulating pages into, until the queue drains and writes it. Keyed by +// tier, so a layer paginating never accumulates into the page beneath it nor writes that url. +const syncing = new Map() /** * As users scroll through infinite content, this system updates the URL to reflect @@ -18,30 +19,39 @@ let initialUrlWasAbsolute: boolean | null = null * so that the URL reflects whichever page has the most visible items. */ export const useInfiniteScrollQueryString = (options: { + layerId?: string getPageName: () => string getItemsElement: () => HTMLElement shouldPreserveUrl: () => boolean }) => { let enabled = true + const tier = options.layerId ?? '' + const queuePageUpdate = (page: string) => { queue .add(() => { return new Promise((resolve) => { if (!enabled) { - initialUrl = payloadUrl = null + syncing.delete(tier) return resolve() } - if (!initialUrl || !payloadUrl) { - const currentPageUrl = currentPage.get().url - initialUrl = hrefToUrl(currentPageUrl) - payloadUrl = hrefToUrl(currentPageUrl) - initialUrlWasAbsolute = urlHasProtocol(currentPageUrl) + let sync = syncing.get(tier) + + if (!sync) { + const tierUrl = tierOf(currentPage.get(), options.layerId).url + + if (tierUrl === null) { + return resolve() + } + + sync = { initial: hrefToUrl(tierUrl), payload: hrefToUrl(tierUrl), absolute: urlHasProtocol(tierUrl) } + syncing.set(tier, sync) } const pageName = options.getPageName() - const searchParams = payloadUrl.searchParams + const searchParams = sync.payload.searchParams // Clean URLs: don't show ?page=1 in the URL, just remove the parameter entirely if (page === '1') { @@ -54,22 +64,19 @@ export const useInfiniteScrollQueryString = (options: { }) }) .finally(() => { - if ( - enabled && - initialUrl && - payloadUrl && - initialUrl.href !== payloadUrl.href && - initialUrlWasAbsolute !== null - ) { + const sync = syncing.get(tier) + + if (enabled && sync && sync.initial.href !== sync.payload.href) { // Update URL without triggering a page reload or affecting scroll position router.replace({ - url: urlToString(payloadUrl, initialUrlWasAbsolute), + url: urlToString(sync.payload, sync.absolute), + layerId: options.layerId, preserveScroll: true, preserveState: true, }) } - initialUrl = payloadUrl = initialUrlWasAbsolute = null + syncing.delete(tier) }) } diff --git a/packages/core/src/initialVisit.ts b/packages/core/src/initialVisit.ts index dc629621b..c6ceb9f1b 100644 --- a/packages/core/src/initialVisit.ts +++ b/packages/core/src/initialVisit.ts @@ -1,8 +1,10 @@ import { eventHandler } from './eventHandler' import { fireFlashEvent, fireNavigateEvent } from './events' import { history } from './history' +import { missingBase } from './layers' import { navigationType } from './navigationType' import { page as currentPage } from './page' +import { walkTo } from './response' import { Scroll } from './scroll' import { SessionStorage } from './sessionStorage' import { LocationVisit, Page } from './types' @@ -36,7 +38,8 @@ export class InitialVisit { .then((data) => { const visitId = uid() - currentPage.set(data, { preserveScroll: true, preserveState: true, visitId }).then(() => { + // A restore never adds an entry, not even where the stack it brings back opens layers. + currentPage.set(data, { replace: true, preserveScroll: true, preserveState: true, visitId }).then(() => { Scroll.restore(scrollRegions) fireNavigateEvent(currentPage.get(), { visitId }) }) @@ -60,10 +63,6 @@ export class InitialVisit { SessionStorage.remove(SessionStorage.locationVisitKey) - if (typeof window !== 'undefined') { - currentPage.setUrlHash(window.location.hash) - } - history .decrypt(currentPage.get()) .then(() => { @@ -72,20 +71,13 @@ export class InitialVisit { const scrollRegions = history.getScrollRegions() currentPage.remember(rememberedState) - currentPage - .set(currentPage.get(), { - preserveScroll: locationVisit.preserveScroll, - preserveState: true, - initialRender: true, - visitId, - }) - .then(() => { - if (locationVisit.preserveScroll) { - Scroll.restore(scrollRegions) - } - - this.fireInitialEvents(visitId) - }) + this.setInitialPage({ preserveScroll: locationVisit.preserveScroll, visitId }).then(() => { + if (locationVisit.preserveScroll) { + Scroll.restore(scrollRegions) + } + + this.fireInitialEvents(visitId) + }) }) .catch(() => { eventHandler.onMissingHistoryItem() @@ -95,23 +87,34 @@ export class InitialVisit { } protected static handleDefault(): void { - if (typeof window !== 'undefined') { - currentPage.setUrlHash(window.location.hash) - } - const visitId = uid() - currentPage - .set(currentPage.get(), { preserveScroll: true, preserveState: true, initialRender: true, visitId }) - .then(() => { - if (navigationType.isReload()) { - Scroll.restore(history.getScrollRegions()) - } else { - Scroll.scrollToAnchor() - } + this.setInitialPage({ preserveScroll: true, visitId }).then(() => { + if (navigationType.isReload()) { + Scroll.restore(history.getScrollRegions()) + } else { + Scroll.scrollToAnchor() + } - this.fireInitialEvents(visitId) - }) + this.fireInitialEvents(visitId) + }) + } + + protected static setInitialPage({ + preserveScroll, + visitId, + }: { + preserveScroll?: boolean + visitId: string + }): Promise { + const page = currentPage.get() + const base = missingBase(page) + + return currentPage.set(page, { preserveScroll, preserveState: true, initialRender: true, visitId }).then(() => { + if (base !== undefined && typeof window !== 'undefined') { + walkTo(base) + } + }) } protected static fireInitialEvents(visitId: string): void { diff --git a/packages/core/src/layerDialog.ts b/packages/core/src/layerDialog.ts new file mode 100644 index 000000000..e538ae585 --- /dev/null +++ b/packages/core/src/layerDialog.ts @@ -0,0 +1,198 @@ +// The `` mechanics behind the shipped Layer, kept here so all three adapters share them and +// an app replacing the `layer` option can build on them. + +import type { LayerShellProps } from './types' + +const exitTimeout = 2000 + +// What the shipped Layer puts on its dialog, including the marks `raiseLayer` reads back below. +export const layerDialogAttributes = (shell: Omit) => ({ + open: true, + 'data-layer-index': shell.index, + 'data-layer-top': String(shell.isTop), + 'data-layer-closing': String(!shell.open), + 'data-layer-type': shell.type, + 'aria-label': shell.label, +}) + +// Chromium groups the close watchers of dialogs shown without user activation, so one Escape can +// reach every dialog in the group. Only the top layer answers it; the rest cannot refuse (their +// cancel is not cancelable), so they are re-shown once the browser has closed them. +export function cancelLayer(event: Event, shell: Pick): void { + event.preventDefault() + + if (shell.isTop) { + shell.close() + + return + } + + const dialog = event.target as HTMLDialogElement + + dialog.addEventListener( + 'close', + () => { + if (dialog.isConnected && dialog.dataset.layerClosing !== 'true') { + raiseLayer(dialog, false) + } + }, + { once: true }, + ) +} + +let scrollLocks = 0 +let unlocked: { overflow: string; scrollbarGutter: string } | null = null + +// Locks the document's scroll, ref-counted across a stack, and returns its own release. +export function lockScroll(): () => void { + const root = document.documentElement + + if (scrollLocks++ === 0) { + unlocked = { overflow: root.style.overflow, scrollbarGutter: root.style.scrollbarGutter } + root.style.overflow = 'hidden' + root.style.scrollbarGutter = 'stable' + } + + let released = false + + return () => { + if (released) { + return + } + + released = true + + if (--scrollLocks === 0 && unlocked) { + root.style.overflow = unlocked.overflow + root.style.scrollbarGutter = unlocked.scrollbarGutter + unlocked = null + } + } +} + +// Puts a dialog on top of the browser's own stack. One already in the top layer has to leave it +// first: Chromium re-orders it in place, WebKit and Firefox leave it where it was, which sends +// Escape to whichever layer entered the top layer last rather than the one on top of the stack. +function showModal(dialog: HTMLDialogElement): void { + if (dialog.matches(':modal')) { + dialog.close() + } + + dialog.removeAttribute('open') + dialog.showModal() +} + +export function raiseLayer(dialog: HTMLDialogElement, isTop: boolean): void { + if (!dialog.matches(':modal')) { + showModal(dialog) + } + + if (isTop) { + return + } + + // A layer inserted beneath open ones mounts after them, so its showModal lands above dialogs it + // belongs under. Re-showing each of those, bottom to top, keeps the stack the right way up. + const index = Number(dialog.dataset.layerIndex) + + document + .querySelectorAll('dialog[data-layer-index]:not([data-layer-closing="true"])') + .forEach((above) => { + if (Number(above.dataset.layerIndex) > index && above.matches(':modal')) { + showModal(above) + } + }) +} + +export interface LayerExit { + /** The layer's `open` changed: false runs its exit, true abandons one already under way. */ + toggle(open: boolean): void + teardown(): void +} + +export function observeExit(dialog: () => HTMLDialogElement | null | undefined, done: () => void): LayerExit { + let exiting = false + let stop: (() => void) | null = null + + const teardown = () => { + stop?.() + stop = null + } + + return { + teardown, + toggle(open) { + if (open) { + teardown() + exiting = false + + const el = dialog() + + // An abandoned close whose exit already ran left the dialog closed, and only re-showing + // it puts the layer back on screen. + if (el && !el.matches(':modal')) { + raiseLayer(el, el.dataset.layerTop === 'true') + } + + return + } + + if (exiting) { + return + } + + exiting = true + + const el = dialog() + + if (!el) { + done() + + return + } + + let finished = false + + const finish = () => { + if (!finished) { + finished = true + teardown() + // Closing is what hands focus back to whatever opened the layer, and only a dialog still + // in the document can. Too late on unmount: React tears effects down after removing it. + el.close() + done() + } + } + + // The subtree counts: a shell that slides a panel inside the dialog animates that, not the + // dialog. One that fills forwards stays listed once it has ended, and an endless one, a + // spinner say, is not something an exit can wait for. + const finishWhenIdle = () => + requestAnimationFrame(() => { + const running = el + .getAnimations({ subtree: true }) + .some( + (animation) => + animation.playState === 'running' && animation.effect?.getComputedTiming().endTime !== Infinity, + ) + + if (!running) { + finish() + } + }) + + const timeout = setTimeout(finish, exitTimeout) + + el.addEventListener('transitionend', finishWhenIdle) + el.addEventListener('animationend', finishWhenIdle) + + stop = () => { + clearTimeout(timeout) + el.removeEventListener('transitionend', finishWhenIdle) + el.removeEventListener('animationend', finishWhenIdle) + } + + requestAnimationFrame(finishWhenIdle) + }, + } +} diff --git a/packages/core/src/layers.ts b/packages/core/src/layers.ts new file mode 100644 index 000000000..33c3c04db --- /dev/null +++ b/packages/core/src/layers.ts @@ -0,0 +1,1063 @@ +import { omit } from 'es-toolkit' +import { router } from '.' +import { history } from './history' +import { page as currentPage } from './page' +import { + BaseSnapshot, + Layer, + LayerShellProps, + LayerState, + LoadingOption, + LocalLayer, + Page, + PageProps, + ResolvedLayer, + UrlMethodPair, +} from './types' +import { hrefToUrl, isSameUrlWithoutHash } from './url' + +let sequence = 0 + +// A layer's component is thrown away and remade unless the write that landed on it preserved state, +// which is what the page's own key does for the base. +let renderSequence = 0 + +export const nextRenderKey = (): number => ++renderSequence + +export const layersOf = (page: Page): LayerState[] => page.layers ?? [] + +export const layerAt = (page: Page, id: string | undefined): LayerState | undefined => + id === undefined ? undefined : layersOf(page).find((layer) => layer.id === id) + +export const tierOf = (page: Page, id: string | undefined): Layer => layerAt(page, id) ?? page + +export const mapLayers = (page: Page, fn: (layer: LayerState) => LayerState): Page => + page.layers ? { ...page, layers: page.layers.map(fn) } : page + +// Writes fields onto one tier: the layer the id names, or the page itself when it names none. +export const withTier = (page: Page, id: string | undefined, fields: Partial): Page => + id === undefined + ? ({ ...page, ...fields } as Page) + : mapLayers(page, (layer) => (layer.id === id ? { ...layer, ...fields } : layer)) + +// History keeps ids the counter has forgotten, so an id is checked against the stack it joins. +export const nextLayerId = (base: Page): string => { + const taken = new Set(layersOf(base).map((layer) => layer.id)) + + let id: string + + do { + id = `layer-${++sequence}` + } while (taken.has(id)) + + return id +} + +export const isLocalLayer = (target: string | URL | UrlMethodPair | LocalLayer): target is LocalLayer => + typeof target === 'object' && 'component' in target + +// The mark's presence is what makes a response a layer, so an empty mark still counts. +export const isLayerResponse = (page: Page): boolean => typeof page.layer === 'object' && page.layer !== null + +export const layerKeyOf = (response: Page): string => response.layer?.key || response.component + +export const layerBaseOf = (response: Page): string | undefined => response.layer?.base + +export const promoteLayer = (response: Page): Page => { + const { layers, layer, ...page } = response + + return page as Page +} + +// A layer composes only while the base it was dispatched from is still the one on screen. +export const capturedBaseIsValid = ({ + captured, + live, + dispatchedUrl, + dispatchedFrom, + opening = false, + layer, +}: { + captured: BaseSnapshot + live: BaseSnapshot + dispatchedUrl: URL + dispatchedFrom?: string + opening?: boolean + layer: { url: string; key: string; base?: string } +}): boolean => { + if (captured.generation !== live.generation) { + return false + } + + if (opening) { + // The call named the layer it wanted, so a create that redirects to the record still opens it. + return true + } + + // A bare router call from inside a layer carries no tier, so the stack it left stands in for one. + const sentFrom = dispatchedFrom ?? layersOf(captured.page).at(-1)?.id + + const dispatchedTowardTheLayer = isSameUrlWithoutHash(dispatchedUrl, hrefToUrl(layer.url)) + const theLayerIsAlreadyOpen = layersOf(live.page).some((open) => open.key === layer.key) + const dispatchedFromALayerStillOpen = layersOf(live.page).some((open) => open.id === sentFrom) + // The layer names the page it stands on and that page is on screen, so a plain create that + // redirects to the record opens over the list it was posted from rather than walking one back. + const standsOnThePageOnScreen = + layer.base !== undefined && isSameUrlWithoutHash(hrefToUrl(layer.base), hrefToUrl(live.page.url)) + + return dispatchedTowardTheLayer || theLayerIsAlreadyOpen || dispatchedFromALayerStillOpen || standsOnThePageOnScreen +} + +// The layer a response belongs to: one open under the same key is that layer coming back, not a new one. +export const openLayerFor = (page: Page, response: Page, id?: string): LayerState | undefined => { + const key = layerKeyOf(response) + const matches = layersOf(page).filter((layer) => !layer.local && layer.key === key) + + return matches.find((layer) => layer.id === id) ?? matches[0] +} + +// What a layer holds by being on the stack, rather than by being a tier. +const stackFields = [ + 'id', + 'key', + 'base', + 'standalone', + 'entries', + 'owner', + 'local', + 'preservesUrl', + 'renderKey', +] as const + +const tierState = (layer: LayerState): Layer => omit(layer, stackFields) + +// What a visit carries into its landing, the id being the one `router.layer()` minted. +export interface CarriedLayer { + base: Page + layerId?: string + opening: boolean + owner: string + claims?: boolean +} + +// An instant visit puts a placeholder in the layer it was aimed at, and only its own response can +// claim that placeholder. Claimed by id rather than by key: the key is the server's to choose, and +// the placeholder went up before the server had said anything. +const claimedPlaceholder = (base: Page, id: string | undefined, claims?: boolean): LayerState | undefined => + claims ? layerAt(base, id) : undefined + +const composedLayerId = (base: Page, response: Page, { layerId, opening, claims }: CarriedLayer): string => { + const open = openLayerFor(base, response, layerId) ?? claimedPlaceholder(base, layerId, claims) + const mints = opening && layerId !== undefined + const taken = layersOf(base).some((layer) => layer.id === layerId) + + const id = open ? open.id : mints && !taken ? layerId : nextLayerId(base) + + if (mints && id !== layerId) { + registryRekey(layerId, id) + } + + return id +} + +export const composeLayer = ( + base: Page, + response: Page, + id: string, + { + url = response.url, + standalone = false, + owner = null, + local = false, + preservesUrl = false, + remount = false, + claims = false, + }: { + url?: string | null + standalone?: boolean + owner?: string | null + local?: boolean + preservesUrl?: boolean + remount?: boolean + claims?: boolean + } = {}, +): Page => { + const layers = layersOf(base) + const rewrites = + standalone || local ? undefined : (openLayerFor(base, response, id) ?? claimedPlaceholder(base, id, claims)) + // Settled when the layer opened: a later partial or poll must not move the address onto it. + const preserves = rewrites ? rewrites.preservesUrl : preservesUrl + + const layer: LayerState = { + id: rewrites?.id ?? id, + key: layerKeyOf(response), + renderKey: rewrites && !remount ? rewrites.renderKey : nextRenderKey(), + component: response.component, + props: response.props, + url, + base: layerBaseOf(response) ?? null, + encryptHistory: response.encryptHistory ?? false, + standalone: rewrites?.standalone ?? standalone, + entries: rewrites?.entries ?? 0, + owner: rewrites?.owner ?? owner, + deferredProps: response.deferredProps ?? {}, + initialDeferredProps: response.initialDeferredProps ?? response.deferredProps, + rescuedProps: response.rescuedProps ?? [], + flash: response.flash ?? {}, + onceProps: response.onceProps ?? {}, + scrollProps: response.scrollProps ?? {}, + ...(local ? { local: true } : {}), + ...(preserves ? { preservesUrl: true } : {}), + } + + return { + ...base, + // The asset version rides on the page, not the tier, so the composite takes the one it landed on. + version: response.version, + clearHistory: base.clearHistory || response.clearHistory, + layers: rewrites ? layers.map((open) => (open === rewrites ? layer : open)) : [...layers, layer], + } +} + +export const composeLocalLayer = (base: Page, component: string, props: PageProps, id: string, owner: string): Page => + composeLayer(base, { component, props, layer: {}, url: null, version: base.version } as unknown as Page, id, { + url: null, + owner, + local: true, + }) + +export const composeColdLayer = ( + response: Page, + id: string, + url: string | null = response.url, + owner: string | null = null, +): Page => + composeLayer( + { + component: '', + props: { errors: {} }, + url: layerBaseOf(response)!, + version: response.version, + rescuedProps: [], + flash: response.flash, + // State is remembered against a page the user was on, and nobody has been on this one. + rememberedState: {}, + } as Page, + response, + id, + { url, standalone: true, owner }, + ) + +export const insertLayerBeneath = (page: Page, response: Page, id: string): Page => { + const composed = composeLayer({ ...page, layers: [] }, response, id, { standalone: true }) + + return { ...composed, layers: [...composed.layers!, ...layersOf(page)] } +} + +// A walk that stops leaves its deepest layer drawn over nothing, so that layer becomes the page. +export const promoteDeepestLayer = (page: Page): Page => { + const [deepest, ...above] = layersOf(page) + + if (!deepest) { + return page + } + + const { layers, ...base } = page + + const promoted = { + ...base, + ...tierState(deepest), + url: deepest.url ?? base.url, + encryptHistory: base.encryptHistory || deepest.encryptHistory, + // Both tiers' flash was announced when it was set, and isn't announced again here. + flash: {}, + } as Page + + return above.length > 0 ? { ...promoted, layers: above } : promoted +} + +export const closeLayer = (page: Page, id: string): Page => { + const index = layersOf(page).findIndex((layer) => layer.id === id) + + if (index === -1) { + return page + } + + const { layers, ...beneath } = page + + return index === 0 ? (beneath as Page) : { ...beneath, layers: layers!.slice(0, index) } +} + +// A layer that never made it onto the stack has no removal to fire its onClose, so the visit does. +export const closeUnlandedLayer = (page: Page, id: string | undefined): void => { + if (id !== undefined && !layersOf(page).some((layer) => layer.id === id)) { + registryClose(id) + } +} + +// The topmost layer whose url the visit that opened it left free to own. +const addressLayer = (page: Page): LayerState | undefined => + layersOf(page) + .filter((layer) => layer.url !== null && !layer.preservesUrl) + .at(-1) + +export const addressOf = (page: Page): string => addressLayer(page)?.url ?? page.url + +// The tier the address belongs to, which is the page itself when no layer owns it. +export const addressTierOf = (page: Page): Layer => addressLayer(page) ?? page + +// A hash belongs on the url the address is read from: on the base it never reaches the address bar. +export const withAddressHash = (page: Page, hash: string): Page => { + const owner = addressLayer(page) + const url = hrefToUrl(owner?.url ?? page.url) + + url.hash = hash + + return withTier(page, owner?.id, { url: url.href }) +} + +// A stack can name a base that has since been remounted: by a restore, or by a detour returning its +// layer over the page the prompt replaced. Left as it was, what the stack emits reaches nobody. +export const withLiveOwners = (page: Page, baseId: string): Page => { + const open = new Set(layersOf(page).map((layer) => layer.id)) + + return mapLayers(page, (layer) => + layer.owner !== null && !open.has(layer.owner) ? { ...layer, owner: baseId } : layer, + ) +} + +// The layer a restore dismisses, if that is what it is: a step back onto the entry the top layer +// was opened over, which is the browser doing by hand what closing the layer does. A layer that +// pushed entries of its own sits further back than one step, and a jump that lands anywhere else +// is a navigation, not a dismissal. +export const layerDismissedByRestore = (page: Page, restored: Page): LayerState | undefined => { + const open = layersOf(page) + const top = open.at(-1) + const beneath = layersOf(restored) + + if (!top || top.entries !== 1 || beneath.length !== open.length - 1) { + return undefined + } + + const stands = (tier: Layer, was: Layer) => tier.component === was.component && tier.url === was.url + + return stands(restored, page) && + beneath.every((layer, index) => layer.id === open[index].id && stands(layer, open[index])) + ? top + : undefined +} + +// A restore whose base is the page already on screen: only the stack moved, and remounting the base +// would throw away the state the stack was opened over. +export const restoreKeepsBase = (page: Page, restored: Page): boolean => + layersOf(restored).length > 0 && restored.component === page.component && restored.url === page.url + +const withoutRemembered = (page: Page, layerIds: string[]): Page => ({ + ...page, + rememberedState: Object.fromEntries( + Object.entries(page.rememberedState ?? {}).filter(([key]) => !layerIds.includes(key)), + ), +}) + +export const withoutFlash = (page: Page): Page => ({ + ...mapLayers(page, (layer) => ({ ...layer, flash: {} })), + flash: {}, +}) + +// One entry holds every tier, so anything short of the union writes one layer's props in plaintext. +export const encryptsHistory = (page: Page): boolean => + !!page.encryptHistory || layersOf(page).some((layer) => layer.encryptHistory) + +// Every entry pushed while a layer is on screen is one that closing it has to step back over. +export const recordHistoryEntry = (page: Page): Page => withTopEntries(page, 1) + +export const dropHistoryEntry = (page: Page): Page => withTopEntries(page, -1) + +const withTopEntries = (page: Page, by: number): Page => { + const top = layersOf(page).at(-1) + + return top === undefined ? page : withTier(page, top.id, { entries: Math.max(0, top.entries + by) }) +} + +export const entriesToUnwind = (page: Page, id: string): number => { + const index = layersOf(page).findIndex((layer) => layer.id === id) + + return index === -1 ? 0 : page.layers!.slice(index).reduce((entries, layer) => entries + layer.entries, 0) +} + +// The entries the dropped layers owned fall to the layer beneath them. +const absorbLayersAbove = (page: Page, id: string): Page => { + const layers = layersOf(page) + const index = layers.findIndex((layer) => layer.id === id) + const entries = entriesToUnwind(page, id) + const kept = layers.slice(0, index) + const beneath = kept.at(-1) + + return { + ...page, + layers: beneath ? [...kept.slice(0, -1), { ...beneath, entries: beneath.entries + entries }] : kept, + } +} + +export const maxLayerChain = 10 + +// A base already on the stack would send the walk round in circles. +const walkContinues = (page: Page, base: string): boolean => { + const layers = layersOf(page) + const fetchedFrom = (url: string | null) => url !== null && isSameUrlWithoutHash(hrefToUrl(url), hrefToUrl(base)) + + if (layers.some((layer) => fetchedFrom(layer.url))) { + return false + } + + if (layers.length >= maxLayerChain) { + console.warn( + `A layer chain more than ${maxLayerChain} layers deep was declared, so "${base}" was not fetched. The deepest layer that loaded is being used as the page.`, + ) + + return false + } + + return true +} + +export const missingBase = (page: Page): string | undefined => + page.component === '' ? (layersOf(page)[0]?.base ?? undefined) : undefined + +// The url a blank base is waiting on: the base its layer declared, or its own once the stack that +// declared it was dismissed and the recovery is fetching it. +export const loadingBase = (page: Page): string | undefined => + missingBase(page) ?? (page.component === '' && layersOf(page).length === 0 && page.url !== '' ? page.url : undefined) + +export interface LayerLanding { + page: Page + walksTo?: string + landedOn?: string + preserveUrl: boolean +} + +export const landLayerResponse = ({ + base, + response, + responseUrl, + composesAsLayer, + walksBeneath, + remount, + carried, + preserveUrl, + keepsStack, +}: { + base: Page + response: Page + responseUrl: string + composesAsLayer: boolean + walksBeneath: boolean + remount: boolean + carried: CarriedLayer + preserveUrl: boolean + keepsStack: boolean +}): LayerLanding => { + const landing: LayerLanding = { page: response, preserveUrl } + const isLayer = isLayerResponse(response) + const standsOn = isLayer ? layerBaseOf(response) : undefined + + if (composesAsLayer) { + const id = composedLayerId(carried.base, response, carried) + const open = layerAt(carried.base, id) + + landing.page = composeLayer(carried.base, response, id, { + url: preserveUrl && open ? open.url : responseUrl, + preservesUrl: preserveUrl, + owner: carried.owner, + remount, + claims: carried.claims, + }) + landing.landedOn = id + // An open is a step of its own, so the layer carries the preservation and writes its entry. + landing.preserveUrl = preserveUrl && !!open + } else if (walksBeneath && standsOn !== undefined) { + const id = nextLayerId(base) + + landing.page = insertLayerBeneath(base, response, id) + landing.walksTo = standsOn + landing.landedOn = id + } else if (walksBeneath) { + landing.page = { ...promoteLayer(response), layers: base.layers } + } else if (standsOn !== undefined) { + // Nothing valid to sit on, but it says what it belongs on, so the walk fetches that beneath it. + const id = carried.layerId ?? nextLayerId(base) + + landing.preserveUrl = false + landing.page = composeColdLayer(response, id, responseUrl, carried.owner) + landing.walksTo = standsOn + landing.landedOn = id + } else if (isLayer) { + landing.page = promoteLayer(response) + } else if (keepsStack) { + landing.page = { ...response, layers: base.layers } + } + + if (landing.walksTo !== undefined && !walkContinues(landing.page, landing.walksTo)) { + landing.page = promoteDeepestLayer(landing.page) + landing.walksTo = undefined + landing.landedOn = undefined + } + + return landing +} + +export interface LayerHandle { + id: string + /** Hears what this layer's immediate children emit, and returns its own unsubscribe. */ + on(name: string, callback: (payload?: unknown, childId?: string) => void): () => void + /** The same, for the first one only. */ + once(name: string, callback: (payload?: unknown, childId?: string) => void): () => void + onClose(callback: () => void): () => void + close(): Promise + /** Sends an event to whatever opened this layer; dropped when that has no handle of its own. */ + emit(name: string, payload?: unknown): void + /** @internal */ + deliver(childId: string, name: string, payload?: unknown): void + /** @internal */ + fireOnClose(): void +} + +// Handles live beside the page, never on it, so no history entry carries them. An id can be +// re-opened while older handles still point at it, so each one holds a list. +const layerRegistry = new Map() + +export const registryWrite = (id: string, entry: LayerHandle): void => { + layerRegistry.set(id, [...(layerRegistry.get(id) ?? []), entry]) +} + +export const registryRead = (id: string): LayerHandle | undefined => layerRegistry.get(id)?.at(-1) + +export const registryHas = (id: string): boolean => layerRegistry.has(id) + +export const registryClose = (id: string): void => { + layerRegistry.get(id)?.forEach((handle) => handle.fireOnClose()) + layerRegistry.delete(id) +} + +// Moves handles onto the id the layer actually composed under. Left where they were minted, they +// would answer for a layer that never opened. +const registryRekey = (from: string, to: string): void => { + const moving = layerRegistry.get(from) + + if (moving === undefined) { + return + } + + moving.forEach((handle) => (handle.id = to)) + layerRegistry.set(to, [...(layerRegistry.get(to) ?? []), ...moving]) + layerRegistry.delete(from) +} + +export const layerHandleFor = (id: string, mint: (id: string) => LayerHandle): LayerHandle => { + if (typeof window === 'undefined') { + // Nothing emits or closes during a render, so the handle is spent with the render that asks. + return mint(id) + } + + const existing = registryRead(id) + + if (existing) { + return existing + } + + const handle = mint(id) + + registryWrite(id, handle) + + return handle +} + +export const createLayerHandle = ( + id: string, + close: (id: string) => Promise, + ownerOf: (id: string) => string | null | undefined, +): LayerHandle => { + const subscriptions = new Map void)[]>() + const onCloseCallbacks: (() => void)[] = [] + + const deliver = (childId: string, name: string, payload?: unknown): void => { + for (const callback of subscriptions.get(name) ?? []) { + callback(payload, childId) + } + } + + const handle: LayerHandle = { + id, + on: (name, callback) => { + subscriptions.set(name, [...(subscriptions.get(name) ?? []), callback]) + + return () => + subscriptions.set( + name, + (subscriptions.get(name) ?? []).filter((subscribed) => subscribed !== callback), + ) + }, + once: (name, callback) => { + const stop = handle.on(name, (payload, childId) => { + stop() + callback(payload, childId) + }) + + return stop + }, + onClose: (callback) => { + onCloseCallbacks.push(callback) + + return () => { + const at = onCloseCallbacks.indexOf(callback) + + if (at > -1) { + onCloseCallbacks.splice(at, 1) + } + } + }, + close: () => close(handle.id), + emit: (name, payload) => { + const owner = ownerOf(handle.id) + + if (owner === null || owner === undefined) { + return + } + + layerRegistry.get(owner)?.forEach((ownerHandle) => ownerHandle.deliver(handle.id, name, payload)) + }, + deliver, + fireOnClose: () => { + for (const callback of onCloseCallbacks) { + callback() + } + }, + } + + return handle +} + +// How long an unwind waits for the browser. Settling early installs a page it is about to replace. +const unwindTimeout = 1000 + +type Deferred = { settled: Promise; settle: () => void } + +const deferred = (): Deferred => { + let settle!: () => void + + return { settled: new Promise((resolve) => (settle = resolve)), settle } +} + +export interface CloseOptions { + // The app asked for the close (router.close / handle.close), which refreshes what it lands on. + programmatic?: boolean + refresh?: (address: string, layerId?: string) => Promise + // The caller installs the page these layers leave behind, so the removal writes nothing itself. + absorbed?: boolean +} + +// The close sequence runs in three steps: a layer is marked, the shell reports back once it has run +// its exit, and only then is it taken off the stack, stepping back over the entries it owned. +class LayerClosing { + // The layers a close is waiting on, deepest first. + protected pending: { id: string; reported: boolean }[] = [] + protected options: CloseOptions | undefined + protected unwinding: (Deferred & { landsItself: boolean }) | null = null + protected closing: Deferred | null = null + protected removing: string[] = [] + + public close(id?: string, options: CloseOptions = {}): Promise { + if (this.unwinding) { + // The last close's layers stay on screen until the browser restores the entry beneath them. + return Promise.resolve() + } + + const layers = layersOf(currentPage.get()) + const index = id === undefined ? layers.length - 1 : layers.findIndex((layer) => layer.id === id) + + if (index === -1) { + return Promise.resolve() + } + + if (this.isClosing(layers[index].id)) { + return this.closing?.settled ?? Promise.resolve() + } + + // Widening restates which layers are owed, not what they have reported. + this.pending = layers.slice(index).map((layer) => ({ + id: layer.id, + reported: this.pending.find((marked) => marked.id === layer.id)?.reported ?? false, + })) + this.options = options + + const closing = (this.closing ??= deferred()) + + return currentPage.rerender().then(() => closing.settled) + } + + // Closes everything above a layer without stepping back, since the caller is about to install + // the page that takes them off the screen. + public async closeAbove(id: string | undefined): Promise { + const layers = layersOf(currentPage.get()) + const index = id === undefined ? -1 : layers.findIndex((layer) => layer.id === id) + const above = index === -1 ? undefined : layers[index + 1] + + if (!above) { + return + } + + await this.close(above.id, { absorbed: true }) + } + + public closed(id?: string): Promise { + const marked = id === undefined ? this.pending.at(-1) : this.pending.find((layer) => layer.id === id) + + if (!marked) { + return Promise.resolve() + } + + marked.reported = true + + if (!this.pending.every((layer) => layer.reported)) { + return Promise.resolve() + } + + const closed = this.pending.map((layer) => layer.id) + const options = this.options + const closing = this.closing + this.pending = [] + this.options = undefined + // Handed to the removal, so a close marked while this one refreshes waits on one of its own. + this.closing = null + + return this.remove(closed, options, closing).finally(() => closing?.settle()) + } + + protected remove( + closed: string[], + { programmatic = false, refresh, absorbed = false }: CloseOptions = {}, + closing?: Deferred | null, + ): Promise { + const layer = layerAt(currentPage.get(), closed[0]) + + if (!layer) { + return Promise.resolve() + } + + if (absorbed) { + // Nothing steps back: the caller pushes its own entry in front of the ones these layers own. + currentPage.merge(withoutRemembered(absorbLayersAbove(currentPage.get(), closed[0]), closed)) + currentPage.dropLayerOptimisticState(closed) + closed.forEach(registryClose) + + return Promise.resolve() + } + + this.removing = closed + + const entries = entriesToUnwind(currentPage.get(), layer.id) + // A layer the user was never behind opened on an entry of its own, so the step back lands on the + // entry still holding it and the close installs the page beneath rather than leaving it to the + // restore. That entry is the one the user arrived on, so the page beneath it is a step of its + // own: only a layer that never got an entry writes over the one it is being closed out of. + const writesTheEntry = layer.standalone || entries === 0 + const replaces = entries === 0 && !layer.standalone + + const land = () => + currentPage.set(closeLayer(currentPage.get(), layer.id), { + replace: replaces, + preserveScroll: true, + preserveState: true, + preservesBase: true, + }) + + let removal: Promise + + if (entries > 0) { + removal = this.beginUnwind(land, writesTheEntry) + history.back(entries) + + if (writesTheEntry) { + removal = removal.then(async () => { + // Unless the wait ran out and already landed it. + if (layerAt(currentPage.get(), layer.id)) { + await land() + } + }) + } + } else { + removal = land() + } + + return removal.then(async () => { + // Off the screen, which is what a close means. The refresh beneath it is a follow-up. + closing?.settle() + + // A layer with no url of its own has nothing to re-ask for, so the base beneath it answers. + const landedOn = layersOf(currentPage.get()).at(-1) + const refreshed = landedOn?.url ? landedOn : undefined + // Unless what it would re-ask for is a blank the walk has yet to fill in, which the recovery + // is already fetching. A walk that landed leaves a base like any other, stale and worth asking. + const recovering = refreshed === undefined && currentPage.get().component === '' + + if (programmatic && refresh && !recovering) { + await refresh(refreshed?.url ?? currentPage.get().url, refreshed?.id) + } + + currentPage.merge(withoutRemembered(currentPage.get(), closed)) + + if (writesTheEntry) { + history.replaceState(currentPage.getWithoutFlashData()) + } + + // Rolling the layer's optimistic back would write its keys into the base's props. + currentPage.dropLayerOptimisticState(closed) + + this.removing = [] + closed.forEach(registryClose) + }) + } + + protected beginUnwind(land: () => Promise, landsItself: boolean): Promise { + const { settled, settle } = deferred() + + // A back() the browser refuses, a step past the start of the session say, fires no popstate, + // so the close lands the page itself once the wait is up. + const refused = setTimeout(() => { + this.unwinding = null + land().then(settle) + }, unwindTimeout) + + this.unwinding = { + landsItself, + settled, + settle: () => { + clearTimeout(refused) + settle() + }, + } + + return settled + } + + // Whether the entry the step back is heading for still holds the layer being closed. The restore + // that answers it would put that layer back on screen, so the close installs the page instead. + public unwindLandsItself(): boolean { + return this.unwinding?.landsItself ?? false + } + + public settleUnwind(): void { + this.unwinding?.settle() + this.unwinding = null + } + + public unwindSettled(): Promise { + return this.unwinding?.settled ?? Promise.resolve() + } + + public isUnwinding(): boolean { + return this.unwinding !== null + } + + public isClosing(id: string): boolean { + return this.pending.some((layer) => layer.id === id) + } + + // Abandons a close whose layer the page being written replaced. + public reconcile(page: Page): void { + if (this.pending.length === 0) { + return + } + + // Read by render key: a supersede remakes the component that was dismissed and is not the one to + // close, while a partial or a poll rewrites the layer where it stands and leaves it closing. + const stands = (layer: LayerState): boolean => { + const landed = layerAt(page, layer.id) + + return landed !== undefined && landed.renderKey === layer.renderKey + } + + if (layersOf(currentPage.get()).every((layer) => !this.isClosing(layer.id) || stands(layer))) { + return + } + + this.pending = [] + this.options = undefined + this.closing?.settle() + this.closing = null + } + + // Fires onClose for every live layer the page that is landing no longer holds. Left to the + // landing rather than the write, since a write a newer one supersedes takes nothing away. + public release(page: Page): void { + const open = new Set(layersOf(page).map((layer) => layer.id)) + + layersOf(currentPage.get()) + .filter((layer) => !open.has(layer.id) && !this.removing.includes(layer.id)) + .forEach((layer) => registryClose(layer.id)) + } + + // The stack as history sees it: a dismissed layer stays on the page for the shell to animate out, + // but must not ride into the entry it is leaving. + public withoutClosingLayers(page: Page): Page { + if (this.pending.length === 0) { + return page + } + + const { layers, ...beneath } = page + const kept = layersOf(page).filter((layer) => !this.isClosing(layer.id)) + + return kept.length > 0 ? { ...beneath, layers: kept } : (beneath as Page) + } +} + +export const layerClosing = new LayerClosing() + +export const layerShellProps = ( + layer: Pick, + index: number, + stack: number, +): Omit => ({ + open: !layer.isClosing, + index, + isTop: index === stack - 1, + type: layer.local ? 'local' : 'routed', + close: () => router.close(layer.id), + done: () => router.closed(layer.id), +}) + +export const layerTransitionName = (id: string): string => `inertia-layer-${id}` + +// The page a layer's own code reads through usePage(): its own state, the composite's for the rest. +// A layer with no url falls through to the address beneath it, never one a layer above it owns. +export const layerPageOf = (page: Page, layer: LayerState): Page => { + const index = layersOf(page).findIndex((open) => open.id === layer.id) + + return { + ...tierState(layer), + props: { ...layer.props, errors: layer.props.errors ?? {} }, + url: layer.url ?? addressOf(index === -1 ? page : { ...page, layers: page.layers!.slice(0, index) }), + version: page.version, + clearHistory: page.clearHistory, + layers: page.layers, + rememberedState: page.rememberedState, + } as Page +} + +export const topPageOf = (page: Page): Page => { + const top = layersOf(page).at(-1) + + return top ? layerPageOf(page, top) : page +} + +// The page a layer's layout resolves against. A layer with no url takes an empty one, so its +// layout never reads the address of the layer beneath it. +export const layoutPageOf = (layer: ResolvedLayer): Page => ({ ...layer.page, url: layer.url ?? '' }) + +export const resolveLayers = ( + page: Page, + resolve: (name: string, page: Page) => ComponentType | Promise, + isClosing: (id: string) => boolean = () => false, +): Promise[]> => + Promise.all( + layersOf(page).map(async (layer) => { + const layerPage = layerPageOf(page, layer) + + return { + ...layer, + page: layerPage, + component: await resolve(layer.component, layerPage), + isClosing: isClosing(layer.id), + } + }), + ) + +// The fragment never reached the server, so it goes back on before anything composes. +const withBrowserHash = (response: Page): Page => { + const hash = typeof window === 'undefined' ? '' : window.location.hash + + return hash && !response.url.includes(hash) ? { ...response, url: response.url + hash } : response +} + +const composeInitialPage = (response: Page): Page => { + // The counter belongs to the document being rendered, and an SSR process renders many. + sequence = 0 + renderSequence = 0 + + const page = withBrowserHash(response) + + if (!isLayerResponse(page)) { + return page + } + + const base = layerBaseOf(page) + + if (base === undefined) { + return promoteLayer(page) + } + + const cold = composeColdLayer(page, nextLayerId(page)) + + return walkContinues(cold, base) ? cold : promoteLayer(page) +} + +// The page a document renders as, which is not always the page it arrived with: a layer response +// opens over a base that still has to be fetched. +export const resolveInitialPage = async ( + response: PageType, + resolve: (name: string, page: Page) => ComponentType | Promise, + resolveLoading?: (url: string, page: Page) => ComponentType | Promise | undefined, +): Promise<{ page: PageType; component?: ComponentType; layers: ResolvedLayer[] }> => { + const page = composeInitialPage(response) as PageType + const base = loadingBase(page) + + const [component, layers] = await Promise.all([ + page.component !== '' + ? resolve(page.component, page) + : base === undefined + ? undefined + : resolveLoading?.(base, page), + resolveLayers(page, resolve), + ]) + + return { page, component, layers } +} + +// Turns the `loading` option, a component or a resolver for one, into the resolver the router +// takes. A component here is whatever the adapter renders; `rendered` recognises the output of one +// called as if it were the resolver, and `normalize` maps a module onto the component it carries. +export const normalizeLoading = ( + loading: LoadingOption | undefined, + { + rendered = () => false, + normalize = (value) => ((value as { default?: ComponentType })?.default || value) as ComponentType, + }: { + rendered?: (value: unknown) => boolean + normalize?: (value: unknown) => ComponentType + } = {}, +): ((url: string, page: Page) => Promise) | undefined => { + if (loading === undefined) { + return undefined + } + + return async (url, page) => { + if (typeof loading !== 'function') { + return normalize(loading) + } + + let resolved: unknown + + try { + resolved = await (loading as (url: string, page: Page) => unknown)(url, page) + } catch { + // A component, not a resolver: it choked on the arguments. + return normalize(loading) + } + + if (resolved == null) { + return undefined + } + + return rendered(resolved) ? normalize(loading) : normalize(resolved) + } +} diff --git a/packages/core/src/layout.ts b/packages/core/src/layout.ts index 64c872faa..1be8a594c 100644 --- a/packages/core/src/layout.ts +++ b/packages/core/src/layout.ts @@ -16,24 +16,37 @@ export type LayoutCallbackReturn = | Record?] | { component: C; props?: Record }> | Partial +export interface SetLayoutProps { + (props: Partial, layerId?: string): void + (name: K, props: Partial, layerId?: string): void + (props: Partial>, layerId?: string): void + (name: string, props: Partial>, layerId?: string): void +} + +export interface LayoutSlot { + shared: Record + named: Record> +} + export interface LayoutPropsStore { - set(props: Partial): void - setFor(name: K, props: Partial): void - get(): { shared: Record; named: Record> } + set: SetLayoutProps + get(): LayoutSlot + getForLayer(layerId: string): LayoutSlot + layerIds(): string[] reset(): void + retainLayers(ids: string[]): void subscribe(callback: () => void): () => void } +export const emptyLayoutSlot: LayoutSlot = { shared: {}, named: {} } + export function createLayoutPropsStore(): LayoutPropsStore { - let shared: Record = {} - let named: Record> = {} - let snapshot = { shared, named } + // Every tier's props under one key. '' is the base. + const slots = new Map() const listeners = new Set<() => void>() let pendingNotify = false - const updateSnapshot = () => { - snapshot = { shared, named } - } + const slotOf = (tier: string): LayoutSlot => slots.get(tier) ?? emptyLayoutSlot const notify = () => { if (pendingNotify) { @@ -47,45 +60,54 @@ export function createLayoutPropsStore(): LayoutPropsStore { }) } - return { - set(props) { - const merged = { ...shared, ...props } + const drop = (tiers: string[]) => { + // Not some(), which would stop at the first tier it took away. + if (tiers.filter((tier) => slots.delete(tier)).length > 0) { + notify() + } + } + + // Without a name the layer takes second place, so the props are whichever argument is not it. + const set = ( + nameOrProps: string | Record, + propsOrLayerId?: Record | string, + layerId?: string, + ): void => { + const name = typeof nameOrProps === 'string' ? nameOrProps : null + const props = (name ? propsOrLayerId : nameOrProps) as Record + const tier = (typeof propsOrLayerId === 'string' ? propsOrLayerId : layerId) ?? '' + + const current = slotOf(tier) + const into = name ? (current.named[name] ?? {}) : current.shared + const merged = { ...into, ...props } + + if (isEqual(into, merged)) { + return + } - if (isEqual(shared, merged)) { - return - } + slots.set(tier, name ? { ...current, named: { ...current.named, [name]: merged } } : { ...current, shared: merged }) + notify() + } - shared = merged - updateSnapshot() - notify() - }, + return { + set: set as SetLayoutProps, - setFor(name, props) { - const current = named[name] || {} - const merged = { ...current, ...props } + get: () => slotOf(''), - if (isEqual(current, merged)) { - return - } + getForLayer: (layerId) => slotOf(layerId), - named = { ...named, [name]: merged } - updateSnapshot() - notify() - }, + layerIds: () => Array.from(slots.keys()).filter((tier) => tier !== ''), - reset() { - shared = {} - named = {} - updateSnapshot() - notify() + reset: () => drop(['']), + + retainLayers(ids) { + drop(Array.from(slots.keys()).filter((tier) => tier !== '' && !ids.includes(tier))) }, subscribe(callback) { listeners.add(callback) return () => listeners.delete(callback) }, - - get: () => snapshot, } } diff --git a/packages/core/src/objectUtils.ts b/packages/core/src/objectUtils.ts index b4fdac80c..3a8a14c37 100644 --- a/packages/core/src/objectUtils.ts +++ b/packages/core/src/objectUtils.ts @@ -97,3 +97,90 @@ export const setPathPreservingIdentity = (target: T, path: string, value: unk return copyAlongPath(target, 0) as T } + +const isStructuredCloneable = (value: unknown): boolean => { + const type = typeof value + + return type !== 'function' && type !== 'symbol' +} + +// Cloned as-is because they cannot hold a function. Error is the exception. Its +// `cause` can, but walking it would strip the error type, which is worse. +const isNativelyCloneable = (value: object): boolean => + value instanceof Date || + value instanceof RegExp || + value instanceof Error || + value instanceof ArrayBuffer || + ArrayBuffer.isView(value) || + (typeof Blob !== 'undefined' && value instanceof Blob) + +// Produces a value structuredClone() accepts. Functions and symbols are dropped +// from objects, and become null in arrays so indices survive. +export const toStructuredCloneable = (value: T): T => { + const seen = new WeakMap() + + const copy = (node: unknown): unknown => { + if (node === null || typeof node !== 'object') { + return node + } + + if (seen.has(node)) { + return seen.get(node) + } + + if (isNativelyCloneable(node)) { + return node + } + + if (Array.isArray(node)) { + const result: unknown[] = [] + seen.set(node, result) + + // Not forEach(), which skips holes and would shift every later index. + for (const item of node) { + result.push(isStructuredCloneable(item) ? copy(item) : null) + } + + return result + } + + if (node instanceof Set) { + const result = new Set() + seen.set(node, result) + node.forEach((item) => { + if (isStructuredCloneable(item)) { + result.add(copy(item)) + } + }) + + return result + } + + if (node instanceof Map) { + const result = new Map() + seen.set(node, result) + node.forEach((item, key) => { + if (isStructuredCloneable(key) && isStructuredCloneable(item)) { + result.set(copy(key), copy(item)) + } + }) + + return result + } + + // Anything else is walked as own enumerable properties: structuredClone() + // discards prototypes anyway. + const result: Record = {} + seen.set(node, result) + + Object.entries(node).forEach(([key, item]) => { + if (isStructuredCloneable(item)) { + result[key] = copy(item) + } + }) + + return result + } + + return copy(value) as T +} diff --git a/packages/core/src/page.ts b/packages/core/src/page.ts index 2e53ad1b1..e19c43b8e 100644 --- a/packages/core/src/page.ts +++ b/packages/core/src/page.ts @@ -3,44 +3,146 @@ import { get, set } from 'es-toolkit/compat' import { eventHandler } from './eventHandler' import { fireNavigateEvent } from './events' import { history } from './history' +import { + addressOf, + dropHistoryEntry, + isLayerResponse, + layerAt, + layerClosing, + layersOf, + loadingBase, + openLayerFor, + recordHistoryEntry, + registryClose, + resolveLayers, + tierOf, + withLiveOwners, + withTier, + withoutFlash, +} from './layers' import { prefetchedRequests } from './prefetched' import { Scroll } from './scroll' -import { Component, FlashData, Page, PageEvent, PageHandler, PageResolver, RouterInitParams, Visit } from './types' +import { + Component, + FlashData, + Layer, + LoadingResolver, + Page, + PageEvent, + PageHandler, + PageResolver, + ResolvedLayer, + RouterInitParams, + Visit, +} from './types' import { hrefToUrl, isSameUrlWithoutHash } from './url' +const baseTier = 'base' + +const tiersOf = (page: Page): [string, Layer][] => [ + ...layersOf(page).map((layer): [string, Layer] => [layer.id, layer]), + [baseTier, page], +] + +// The groups a tier arrived owing, held from the write that carries them until one lands: a write +// a newer one supersedes never gets that far, and leaves them for the write that does. +const pendingDeferred = new Map>() + +// A write aimed at one tier carries every other tier along untouched, groups and all, so only the +// groups a write actually brings are recorded. The document's own page brings its own. +const recordDeferredProps = (page: Page, onScreen?: Page): void => { + const carried = new Map(onScreen ? tiersOf(onScreen).map(([key, tier]) => [key, tier.deferredProps]) : []) + + for (const [key, tier] of tiersOf(page)) { + if (Object.keys(tier.deferredProps ?? {}).length && tier.deferredProps !== carried.get(key)) { + pendingDeferred.set(key, { deferredProps: tier.deferredProps, component: tier.component, url: tier.url }) + } + } +} + +const announceDeferredProps = (page: Page): void => { + const tiers = tiersOf(page) + const open = new Set(tiers.map(([key]) => key)) + + for (const key of pendingDeferred.keys()) { + if (!open.has(key)) { + pendingDeferred.delete(key) + } + } + + for (const [key, tier] of tiers) { + const owed = pendingDeferred.get(key) + + if (!owed) { + continue + } + + pendingDeferred.delete(key) + + // The tier moved on while the write was out, so what it was owing is not what it wants now. + if (owed.component !== tier.component || owed.url !== tier.url) { + continue + } + + eventHandler.fireInternalEvent('loadDeferredProps', { + deferredProps: owed.deferredProps, + layerId: key === baseTier ? undefined : key, + }) + } +} + +let baseSequence = 0 + +const nextBaseId = (): string => `base-${++baseSequence}` + class CurrentPage { protected page!: Page protected swapComponent!: PageHandler protected resolveComponent!: PageResolver + protected resolveLoading?: LoadingResolver protected onFlashCallback?: (flash: Page['flash']) => void protected componentId = {} + protected baseGeneration = 0 + protected baseId = '' protected listeners: { event: PageEvent callback: VoidFunction }[] = [] protected isFirstPageLoad = true protected cleared = false - protected pendingDeferredProps: Pick | null = null protected historyQuotaExceeded = false - protected optimisticBaseline: Partial = {} - protected pendingOptimistics: { id: number; callback: (props: Page['props']) => Partial | void }[] = [] + protected historyEntryDropped = false + // Optimistic baselines per prop, under the tier that owns them. '' is the base. + protected optimisticBaselines: Map> = new Map() + protected pendingOptimistics: { + id: number + layerId?: string + callback: (props: Page['props']) => Partial | void + }[] = [] protected optimisticCounter = 0 public init({ initialPage, swapComponent, resolveComponent, + resolveLoading, onFlash, }: RouterInitParams) { this.page = { ...initialPage, flash: initialPage.flash ?? {}, rescuedProps: initialPage.rescuedProps ?? [] } this.swapComponent = swapComponent this.resolveComponent = resolveComponent + this.resolveLoading = resolveLoading this.onFlashCallback = onFlash + this.baseId = nextBaseId() eventHandler.on('historyQuotaExceeded', () => { this.historyQuotaExceeded = true }) + eventHandler.on('historyEntryDropped', () => { + this.historyEntryDropped = true + }) + return this } @@ -53,6 +155,7 @@ class CurrentPage { viewTransition = false, cached = false, initialRender = false, + preservesBase = false, visitId, }: { replace?: boolean @@ -61,49 +164,75 @@ class CurrentPage { viewTransition?: Visit['viewTransition'] cached?: boolean initialRender?: boolean + preservesBase?: boolean visitId?: string } = {}, ): Promise { - if (Object.keys(page.deferredProps || {}).length) { - this.pendingDeferredProps = { - deferredProps: page.deferredProps, - component: page.component, - url: page.url, - } - + if (Object.keys(page.deferredProps || {}).length && page.initialDeferredProps === undefined) { // Preserve original deferred props for back button handling - if (page.initialDeferredProps === undefined) { - page.initialDeferredProps = page.deferredProps - } + page.initialDeferredProps = page.deferredProps + } + + recordDeferredProps(page, initialRender ? undefined : this.page) + + if (!preservesBase) { + // A write in flight is enough to invalidate the base for anything composing onto it. + this.baseGeneration++ } + layerClosing.reconcile(page) + this.componentId = {} const componentId = this.componentId if (page.clearHistory) { history.clear() + // Spent on a copy, so the flag rides into neither this entry nor the stack composing onto it. + page = { ...page, clearHistory: false } } - return this.resolve(page.component, page).then((component) => { + return Promise.all([this.resolve(page.component, page), this.resolveLayers(page)]).then(([component, layers]) => { if (componentId !== this.componentId) { // Component has changed since we started resolving this component, bail return } + if (!preservesBase && this.takesBaseAway(page, preserveState)) { + registryClose(this.baseId) + this.baseId = nextBaseId() + } + + page = withLiveOwners(page, this.baseId) + + layerClosing.release(page) + page.rememberedState ??= {} const isServer = typeof window === 'undefined' const location = !isServer ? window.location : new URL(page.url) const scrollRegions = !isServer && preserveScroll ? Scroll.getScrollRegions() : [] - replace = replace || isSameUrlWithoutHash(hrefToUrl(page.url), location) + const scrollRegionLayers = !isServer && preserveScroll ? Scroll.getScrollRegionLayers() : undefined + // A layer open is a step of its own, so it pushes even where the address has not moved. + const opensLayer = layersOf(page).some((layer) => !layer.standalone && !layerAt(this.page, layer.id)) + + replace = replace || (!opensLayer && isSameUrlWithoutHash(hrefToUrl(addressOf(page)), location)) + + if (!replace && !history.preserveUrl) { + page = recordHistoryEntry(page) + } // Clear flash data from the page object, we don't want it when navigating back/forward... - const pageForHistory = { ...page, flash: {} } + const pageForHistory = withoutFlash(page) return new Promise((resolve) => replace ? history.replaceState(pageForHistory, resolve) : history.pushState(pageForHistory, resolve), ).then(() => { + if (this.historyEntryDropped) { + this.historyEntryDropped = false + page = dropHistoryEntry(page) + } + const isNewComponent = !this.isTheSame(page) if (!isNewComponent && Object.keys(page.props.errors || {}).length > 0) { @@ -137,6 +266,7 @@ class CurrentPage { return this.swap({ component, + layers, page, preserveState, viewTransition, @@ -146,20 +276,12 @@ class CurrentPage { // Scroll regions must be explicitly restored since the DOM elements are destroyed // and recreated during the component 'swap'. Document scroll is naturally // preserved as the document element itself persists across navigations. - window.requestAnimationFrame(() => Scroll.restoreScrollRegions(scrollRegions)) + window.requestAnimationFrame(() => Scroll.restoreScrollRegions(scrollRegions, scrollRegionLayers)) } else { Scroll.reset() } - if ( - this.pendingDeferredProps && - this.pendingDeferredProps.component === page.component && - this.pendingDeferredProps.url === page.url - ) { - eventHandler.fireInternalEvent('loadDeferredProps', this.pendingDeferredProps.deferredProps) - } - - this.pendingDeferredProps = null + announceDeferredProps(page) if (!replace) { fireNavigateEvent(page, { cached, visitId }) @@ -177,11 +299,19 @@ class CurrentPage { preserveState?: boolean } = {}, ) { - return this.resolve(page.component, page).then((component) => { + page = withLiveOwners(page, this.baseId) + + this.baseGeneration++ + layerClosing.reconcile(page) + layerClosing.release(page) + + return this.resolve(page.component, page).then(async (component) => { + const layers = await this.resolveLayers(page) + this.page = page this.cleared = false history.setCurrent(page) - return this.swap({ component, page, preserveState, viewTransition: false }) + return this.swap({ component, layers, page, preserveState, viewTransition: false }) }) } @@ -197,35 +327,55 @@ class CurrentPage { return this.page } + public generation(): number { + return this.baseGeneration + } + + public id(): string { + return this.baseId + } + + // A base the landing page still stands on keeps its id, and with it the handles hung off it. + protected takesBaseAway(page: Page, preserveState: boolean): boolean { + return !layersOf(page).some((open) => layerAt(this.page, open.id)) && !(preserveState && this.isTheSame(page)) + } + public getWithoutFlashData(): Page { - return { ...this.page, flash: {} } + return withoutFlash(this.page) } public hasOnceProps(): boolean { - return Object.keys(this.page.onceProps ?? {}).length > 0 + return tiersOf(this.page).some(([, tier]) => Object.keys(tier.onceProps ?? {}).length > 0) } public merge(data: Partial): void { this.page = { ...this.page, ...data } } - public setPropsQuietly(props: Page['props']): Promise { - this.page = { ...this.page, props } + public setPropsQuietly(props: Page['props'], layerId?: string): Promise { + this.page = withTier(this.page, layerId, { props }) - return this.resolve(this.page.component, this.page).then((component) => { - return this.swap({ component, page: this.page, preserveState: true, viewTransition: false }) - }) + return this.rerender() } - public setFlash(flash: FlashData): void { - this.page = { ...this.page, flash } - this.onFlashCallback?.(flash) + public rerender(): Promise { + return Promise.all([this.resolve(this.page.component, this.page), this.resolveLayers(this.page)]).then( + ([component, layers]) => + this.swap({ component, layers, page: this.page, preserveState: true, viewTransition: false }).then(() => {}), + ) } - public setUrlHash(hash: string): void { - if (!this.page.url.includes(hash)) { - this.page.url += hash + public setFlash(flash: FlashData, layerId?: string): void { + this.page = withTier(this.page, layerId, { flash }) + + if (layerId) { + // The onFlash callback lands on the composite's own flash, where a layer's has no business. + this.rerender() + + return } + + this.onFlashCallback?.(flash) } public remember(data: Page['rememberedState']): void { @@ -234,18 +384,20 @@ class CurrentPage { public swap({ component, + layers, page, preserveState, viewTransition, initialRender = false, }: { - component: Component + component?: Component + layers?: ResolvedLayer[] page: Page preserveState: boolean viewTransition: Visit['viewTransition'] initialRender?: boolean }): Promise { - const doSwap = () => this.swapComponent({ component, page, preserveState, initialRender }) + const doSwap = () => this.swapComponent({ component, layers, page, preserveState, initialRender }) if (!viewTransition || !document?.startViewTransition || document.visibilityState === 'hidden') { return doSwap() @@ -264,53 +416,89 @@ class CurrentPage { }) } - public resolve(component: string, page?: Page): Promise { - return Promise.resolve(this.resolveComponent(component, page)) + public resolve(component: string, page?: Page): Promise { + if (component !== '') { + return Promise.resolve(this.resolveComponent(component, page)) + } + + const base = page === undefined ? undefined : loadingBase(page) + + return Promise.resolve(base === undefined ? undefined : this.resolveLoading?.(base, page!)) + } + + protected resolveLayers(page: Page): Promise { + if (!page.layers?.length) { + return Promise.resolve(undefined) + } + + return resolveLayers( + page, + (name, layerPage) => this.resolve(name, layerPage), + (id) => layerClosing.isClosing(id), + ) } public nextOptimisticId(): number { return ++this.optimisticCounter } - public setBaseline(key: string, value: unknown): void { - if (!(key in this.optimisticBaseline)) { - this.optimisticBaseline[key] = value + protected baselineOf(layerId?: string): Partial { + return this.optimisticBaselines.get(layerId ?? '') ?? {} + } + + public setBaseline(key: string, value: unknown, layerId?: string): void { + const baseline = this.baselineOf(layerId) + + if (!(key in baseline)) { + this.optimisticBaselines.set(layerId ?? '', { ...baseline, [key]: value }) } } - public updateBaseline(key: string, value: unknown): void { - if (key in this.optimisticBaseline) { - this.optimisticBaseline[key] = value + public updateBaseline(key: string, value: unknown, layerId?: string): void { + const baseline = this.baselineOf(layerId) + + if (key in baseline) { + this.optimisticBaselines.set(layerId ?? '', { ...baseline, [key]: value }) } } - public hasBaseline(key: string): boolean { - return key in this.optimisticBaseline + public hasBaseline(key: string, layerId?: string): boolean { + return key in this.baselineOf(layerId) } - public registerOptimistic(id: number, callback: (props: Page['props']) => Partial | void): void { - this.pendingOptimistics.push({ id, callback }) + public registerOptimistic( + id: number, + layerId: string | undefined, + callback: (props: Page['props']) => Partial | void, + ): void { + this.pendingOptimistics.push({ id, layerId, callback }) } public unregisterOptimistic(id: number): void { this.pendingOptimistics = this.pendingOptimistics.filter((entry) => entry.id !== id) } - public replayOptimistics(): Partial { - const baselineKeys = Object.keys(this.optimisticBaseline) + public replayOptimistics(layerId?: string): Partial { + const tierProps = tierOf(this.page, layerId).props + const baseline = this.baselineOf(layerId) + const baselineKeys = Object.keys(baseline) if (baselineKeys.length === 0) { return {} } - const props = cloneDeep(this.page.props) + const props = cloneDeep(tierProps) as Record for (const key of baselineKeys) { - props[key] = cloneDeep(this.optimisticBaseline[key]) + props[key] = cloneDeep(baseline[key]) } - for (const { callback } of this.pendingOptimistics) { - const result = callback(cloneDeep(props)) + for (const { callback, layerId: entryTier } of this.pendingOptimistics) { + if (entryTier !== layerId) { + continue + } + + const result = callback(cloneDeep(props) as Page['props']) if (result) { Object.assign(props, result) @@ -331,10 +519,20 @@ class CurrentPage { } public clearOptimisticState(): void { - this.optimisticBaseline = {} + this.optimisticBaselines.clear() this.pendingOptimistics = [] } + public dropLayerOptimisticState(layerIds: string[]): void { + for (const id of layerIds) { + this.optimisticBaselines.delete(id) + } + + this.pendingOptimistics = this.pendingOptimistics.filter( + (entry) => !entry.layerId || !layerIds.includes(entry.layerId), + ) + } + public isTheSame(page: Page): boolean { return this.page.component === page.component } @@ -351,16 +549,29 @@ class CurrentPage { this.listeners.filter((listener) => listener.event === event).forEach((listener) => listener.callback()) } - public mergeOncePropsIntoResponse(response: Page, { force = false }: { force?: boolean } = {}): void { + public mergeOncePropsIntoResponse( + response: Page, + { force = false, layerId }: { force?: boolean; layerId?: string } = {}, + ): void { + const isLayer = isLayerResponse(response) + const open = isLayer ? openLayerFor(this.page, response, layerId) : undefined + + if (isLayer && !open) { + return + } + + const onceBag = open?.onceProps ?? this.page.onceProps + const props = open?.props ?? this.page.props + Object.entries(response.onceProps ?? {}).forEach(([key, onceProp]) => { - const existingOnceProp = this.page.onceProps?.[key] + const existingOnceProp = onceBag?.[key] if (existingOnceProp === undefined) { return } if (force || get(response.props, onceProp.prop) === undefined) { - set(response.props, onceProp.prop, get(this.page.props, existingOnceProp.prop)) + set(response.props, onceProp.prop, get(props, existingOnceProp.prop)) response.onceProps![key].expiresAt = existingOnceProp.expiresAt } }) diff --git a/packages/core/src/partialReload.ts b/packages/core/src/partialReload.ts index 244e2f383..adedfb77e 100644 --- a/packages/core/src/partialReload.ts +++ b/packages/core/src/partialReload.ts @@ -1,4 +1,5 @@ -import { ActiveVisit } from './types' +import { ActiveVisit, PendingVisit } from './types' +import { hrefToUrl, isSameUrlWithoutQueryOrHash } from './url' type VisitFilter = Pick @@ -27,3 +28,15 @@ export const partialReloadRequestsProp = (visit: VisitFilter, prop: string): boo export const partialReloadRequestsSomeProps = (visit: VisitFilter, props: string[]): boolean => { return props.some((prop) => partialReloadRequestsProp(visit, prop)) } + +// Whether a visit is the one filling in a tier's deferred props. Two tiers can sit on the same url +// waiting on the same prop, so the tier has to match as well. +export const partialReloadFillsDeferred = ( + visit: PendingVisit, + tier: { layerId?: string; url: string }, + props: string[], +): boolean => + visit.preserveState === true && + visit.layerId === tier.layerId && + isSameUrlWithoutQueryOrHash(visit.url, hrefToUrl(tier.url)) && + partialReloadRequestsSomeProps(visit, props) diff --git a/packages/core/src/prefetched.ts b/packages/core/src/prefetched.ts index ebf94f1a8..df5eadecb 100644 --- a/packages/core/src/prefetched.ts +++ b/packages/core/src/prefetched.ts @@ -6,6 +6,7 @@ import { Response } from './response' import { timeToMs } from './time' import { ActiveVisit, + BaseSnapshot, CacheForOption, InFlightPrefetch, InternalActiveVisit, @@ -188,7 +189,7 @@ class PrefetchedRequests { return this.findCached(params) || this.findInFlight(params) } - public use(prefetched: PrefetchedResponse | InFlightPrefetch, params: ActiveVisit) { + public use(prefetched: PrefetchedResponse | InFlightPrefetch, params: ActiveVisit, capturedBase: BaseSnapshot) { const id = `${params.url.pathname}-${Date.now()}-${Math.random().toString(36).substring(7)}` this.currentUseId = id @@ -207,6 +208,9 @@ class PrefetchedRequests { response.mergeParams({ ...consumedParams, onPrefetched: () => {} }) + // The visit replaying this was dispatched from the page on screen, not the one it came from. + response.setCapturedBase(capturedBase) + // If this was a one-time cache, remove it // (generally a prefetch="click" request with no specified cache value) this.removeSingleUseItems(params) @@ -250,7 +254,7 @@ class PrefetchedRequests { } protected paramsAreEqual(params1: ActiveVisit, params2: ActiveVisit): boolean { - return objectsAreEqual( + return objectsAreEqual( this.withoutPurposePrefetchHeader(params1), this.withoutPurposePrefetchHeader(params2), [ @@ -278,6 +282,9 @@ class PrefetchedRequests { 'component', 'pageProps', 'cached', + 'fabricatedLayer', + 'layerId', + 'layerOwner', ], ) } diff --git a/packages/core/src/queue.ts b/packages/core/src/queue.ts index 257a782c8..238bcb9a9 100644 --- a/packages/core/src/queue.ts +++ b/packages/core/src/queue.ts @@ -1,27 +1,32 @@ export default class Queue { protected items: (() => T)[] = [] - protected processingPromise: Promise | null = null + protected running = false + protected processingPromise: Promise = Promise.resolve() public add(item: () => T) { this.items.push(item) + return this.process() } public process() { - this.processingPromise ??= this.processNext().finally(() => { - this.processingPromise = null - }) + if (!this.running) { + this.running = true + this.processingPromise = this.drain() + } return this.processingPromise } - protected processNext(): Promise { - const next = this.items.shift() - - if (next) { - return Promise.resolve(next()).then(() => this.processNext()) + // The flag falls in the same tick as the check that ended the loop, so an item added from here on + // starts a run of its own rather than joining a promise that has already settled. + protected async drain(): Promise { + try { + for (let next = this.items.shift(); next; next = this.items.shift()) { + await next() + } + } finally { + this.running = false } - - return Promise.resolve() } } diff --git a/packages/core/src/request.ts b/packages/core/src/request.ts index ee5a409a3..361889b76 100644 --- a/packages/core/src/request.ts +++ b/packages/core/src/request.ts @@ -9,10 +9,11 @@ import { import { http } from './http' import { HttpCancelledError, HttpResponseError } from './httpErrors' import { interceptors } from './interceptors' +import { closeUnlandedLayer, layerAt } from './layers' import { page as currentPage } from './page' import { RequestParams } from './requestParams' import { Response } from './response' -import type { ActiveVisit, Page } from './types' +import type { ActiveVisit, BaseSnapshot, Page } from './types' import { HttpProgressEvent, HttpRequestConfig, HttpRequestHeaders } from './types' import { urlWithoutHash } from './url' @@ -26,6 +27,7 @@ export class Request { constructor( params: ActiveVisit, protected page: Page, + protected capturedBase: BaseSnapshot, { optimistic = false }: { optimistic?: boolean } = {}, ) { this.requestParams = RequestParams.create(params) @@ -33,8 +35,13 @@ export class Request { this.optimistic = optimistic } - public static create(params: ActiveVisit, page: Page, options?: { optimistic?: boolean }): Request { - return new Request(params, page, options) + public static create( + params: ActiveVisit, + page: Page, + capturedBase: BaseSnapshot, + options?: { optimistic?: boolean }, + ): Request { + return new Request(params, page, capturedBase, options) } public isPrefetch(): boolean { @@ -45,6 +52,10 @@ export class Request { return this.requestParams.all().url } + public get layerId(): string | undefined { + return this.requestParams.all().layerId + } + public isOptimistic(): boolean { return this.optimistic } @@ -91,14 +102,14 @@ export class Request { .getClient() .request(processedConfig) .then((response) => { - this.response = Response.create(this.requestParams, response, this.page) + this.response = Response.create(this.requestParams, response, this.page, this.capturedBase) return this.response.handle() }) .catch((error) => { // Handle HTTP error responses (4xx/5xx) if (error instanceof HttpResponseError) { - this.response = Response.create(this.requestParams, error.response, this.page) + this.response = Response.create(this.requestParams, error.response, this.page, this.capturedBase) return this.response.handle() } @@ -111,6 +122,10 @@ export class Request { return } + // A request that never brought a response back leaves nothing to open the layer it was + // aimed at, so the attempt is spent here as it is on every other terminal path. + closeUnlandedLayer(currentPage.get(), this.requestParams.all().layerId) + if (this.requestParams.all().onNetworkError(error) === false) { return } @@ -163,6 +178,8 @@ export class Request { this.requestParams.markAsCancelled({ cancelled, interrupted }) + closeUnlandedLayer(currentPage.get(), this.requestParams.all().layerId) + this.fireFinishEvents() } @@ -187,16 +204,22 @@ export class Request { headers['X-Inertia-Version'] = page.version } - const onceProps = Object.entries(page.onceProps || {}) - .filter(([, onceProp]) => { - if (get(page.props, onceProp.prop) === undefined) { - // The prop could deferred and not be loaded yet - return false - } - - return !onceProp.expiresAt || onceProp.expiresAt > Date.now() - }) - .map(([key]) => key) + // A request names its own tier's once keys in the except header, never a union with another's. + // A visit opening a layer has no tier of its own yet, so it names none. + const { layerId } = this.requestParams.all() + const tier = layerId === undefined ? page : layerAt(page, layerId) + const onceProps = tier + ? Object.entries(tier.onceProps || {}) + .filter(([, onceProp]) => { + if (get(tier.props, onceProp.prop) === undefined) { + // The prop could deferred and not be loaded yet + return false + } + + return !onceProp.expiresAt || onceProp.expiresAt > Date.now() + }) + .map(([key]) => key) + : [] if (onceProps.length > 0) { headers['X-Inertia-Except-Once-Props'] = onceProps.join(',') diff --git a/packages/core/src/requestParams.ts b/packages/core/src/requestParams.ts index 9f50d8322..8602af8f4 100644 --- a/packages/core/src/requestParams.ts +++ b/packages/core/src/requestParams.ts @@ -1,3 +1,4 @@ +import { tierOf } from './layers' import { page as currentPage } from './page' import { Response } from './response' import { @@ -75,6 +76,18 @@ export class RequestParams { return this.params.poll === true } + public isReload() { + return this.params.reload === true + } + + public isWalkRequest() { + return this.params.walk === true + } + + public fabricatedLayer() { + return this.params.fabricatedLayer === true + } + public onCancelToken(cb: VoidFunction) { this.params.onCancelToken({ cancel: cb, @@ -133,7 +146,8 @@ export class RequestParams { } if (this.isPartial()) { - headers['X-Inertia-Partial-Component'] = currentPage.get().component + // A partial names its own tier's component, so the server answers that route's partial. + headers['X-Inertia-Partial-Component'] = tierOf(currentPage.get(), this.params.layerId).component } const only = this.params.only.concat(this.params.reset) diff --git a/packages/core/src/response.ts b/packages/core/src/response.ts index b074a8832..c0829aff6 100644 --- a/packages/core/src/response.ts +++ b/packages/core/src/response.ts @@ -13,15 +13,279 @@ import { } from './events' import { history } from './history' import { interceptors } from './interceptors' +import { + CarriedLayer, + addressOf, + addressTierOf, + capturedBaseIsValid, + closeUnlandedLayer, + isLayerResponse, + landLayerResponse, + layerAt, + layerBaseOf, + layerClosing, + layerKeyOf, + layersOf, + loadingBase, + openLayerFor, + promoteDeepestLayer, + tierOf, + withTier, +} from './layers' import { page as currentPage } from './page' import { partialReloadRequestsProp } from './partialReload' import Queue from './queue' import { RequestParams } from './requestParams' import { SessionStorage } from './sessionStorage' -import { ActiveVisit, ErrorBag, Errors, HttpResponse, Page, PageProps } from './types' -import { hrefToUrl, isSameUrlWithoutHash, setHashIfSameUrl } from './url' +import { + ActiveVisit, + BaseSnapshot, + ErrorBag, + Errors, + HttpResponse, + Layer, + LayerState, + Page, + PageProps, + VisitOptions, +} from './types' +import { hrefToUrl, isSameUrlWithoutHash, setHashIfSameUrl, urlWithoutHash } from './url' + +const responseQueue = new Queue>() + +// What a marked detour stashed, so the return trip composes over the page the user was on. +interface InterstitialCarry { + layer: CarriedLayer + dispatchedUrl: string +} + +let interstitialCarry: InterstitialCarry | undefined + +// Only the layer the detour interrupted may claim the stash, never one opened on the prompt. +const interstitialCarryFor = (url: string): InterstitialCarry | undefined => + interstitialCarry && isSameUrlWithoutHash(hrefToUrl(interstitialCarry.dispatchedUrl), hrefToUrl(url)) + ? interstitialCarry + : undefined + +export const landWalk = (walkedFrom: number): Promise => { + const page = currentPage.get() + + if (walkedFrom !== currentPage.generation()) { + return Promise.resolve() + } + + if (!layerClosing.withoutClosingLayers(page).layers?.length) { + // Nothing to promote: the page is the blank base, which never renders. Re-request its own url. + return Promise.resolve(router.visit(page.url, { replace: true, preserveScroll: true, preserveState: true })) + } + + return currentPage.set(promoteDeepestLayer(page), { + replace: true, + preserveScroll: true, + preserveState: true, + preservesBase: true, + }) +} + +export const walkTo = (base: string): void => { + const walkedFrom = currentPage.generation() + + // A hop that brings back no page leaves the stack drawn over a base that is never coming. + const land = () => { + responseQueue.add(() => landWalk(walkedFrom)) + + return false + } + + router.visit(base, { + walk: true, + async: true, + replace: true, + preserveScroll: true, + preserveState: true, + onHttpException: land, + onNetworkError: land, + } as VisitOptions & { walk: true }) +} + +function isObject(item: any): boolean { + return item && typeof item === 'object' && !Array.isArray(item) +} + +function hasUniqueProperty(item: any, property: string): boolean { + return item && typeof item === 'object' && property in item +} + +function appendWithMatching( + existingItems: any[], + newItems: any[], + newItemsMap: Map, + uniqueProperty: string, +): any[] { + // Update existing items with new values, keep non-matching items + const updatedExisting = existingItems.map((item) => { + if (hasUniqueProperty(item, uniqueProperty) && newItemsMap.has(item[uniqueProperty])) { + return newItemsMap.get(item[uniqueProperty]) + } + + return item + }) + + // Filter new items to only include those not already in existing items + const newItemsToAdd = newItems.filter((item) => { + if (!hasUniqueProperty(item, uniqueProperty)) { + return true // Always add items without unique property + } + + return !existingItems.some( + (existing) => hasUniqueProperty(existing, uniqueProperty) && existing[uniqueProperty] === item[uniqueProperty], + ) + }) + + return [...updatedExisting, ...newItemsToAdd] +} + +function prependWithMatching( + existingItems: any[], + newItems: any[], + newItemsMap: Map, + uniqueProperty: string, +): any[] { + // Filter existing items, keeping only those not being updated + const untouchedExisting = existingItems.filter((item) => { + if (hasUniqueProperty(item, uniqueProperty)) { + return !newItemsMap.has(item[uniqueProperty]) + } + + return true + }) + + return [...newItems, ...untouchedExisting] +} + +function mergeOrMatchItems( + existingItems: any[], + newItems: any[], + matchProp: string, + matchPropsOn: string[], + shouldAppend = true, +) { + const items = Array.isArray(existingItems) ? existingItems : [] + + // Find the matching key for this specific property path + const matchingKey = matchPropsOn.find((key) => { + const keyPath = key.split('.').slice(0, -1).join('.') + + return keyPath === matchProp + }) + + // If no matching key is configured, simply concatenate the arrays + if (!matchingKey) { + return shouldAppend ? [...items, ...newItems] : [...newItems, ...items] + } + + // Extract the property name we'll use to match items (e.g., 'id' from 'users.data.id') + const uniqueProperty = matchingKey.split('.').pop() || '' + + // Create a map of new items by their unique property lookups + const newItemsMap = new Map() + + newItems.forEach((item) => { + if (hasUniqueProperty(item, uniqueProperty)) { + newItemsMap.set(item[uniqueProperty], item) + } + }) + + return shouldAppend + ? appendWithMatching(items, newItems, newItemsMap, uniqueProperty) + : prependWithMatching(items, newItems, newItemsMap, uniqueProperty) +} + +function mergeProp( + pageResponse: Page, + currentProps: PageProps, + prop: string, + shouldAppend: boolean, + matchPropsOn: string[], +): void { + const currentProp = get(currentProps, prop) + const incomingProp = get(pageResponse.props, prop) + + if (Array.isArray(incomingProp)) { + const newArray = mergeOrMatchItems((currentProp || []) as any[], incomingProp, prop, matchPropsOn, shouldAppend) + + set(pageResponse.props, prop, newArray) + } else if (typeof incomingProp === 'object' && incomingProp !== null) { + const newObject = { + ...(currentProp || {}), + ...incomingProp, + } + + set(pageResponse.props, prop, newObject) + } +} + +function deepMergeProp(target: any, source: any, matchProp: string, matchPropsOn: string[]): any { + if (Array.isArray(source)) { + return mergeOrMatchItems(target, source, matchProp, matchPropsOn) + } + + if (typeof source === 'object' && source !== null) { + // Merge objects by iterating over keys + return Object.keys(source).reduce( + (acc, key) => { + acc[key] = deepMergeProp(target ? target[key] : undefined, source[key], `${matchProp}.${key}`, matchPropsOn) + return acc + }, + { ...target }, + ) + } + + // If the source is neither an array nor an object, simply return it + return source +} + +function deepMergeObjects(target: PageProps, source: PageProps): PageProps { + const result = { ...target } + + for (const key of Object.keys(source)) { + const targetValue = target[key] + const sourceValue = source[key] + + if (isObject(targetValue) && isObject(sourceValue)) { + result[key] = deepMergeObjects(targetValue as PageProps, sourceValue as PageProps) + } else { + result[key] = sourceValue + } + } + + return result +} + +function mergePropsInto(pageResponse: Page, currentProps: PageProps, nestedProps: string[]): void { + const matchPropsOn = pageResponse.matchPropsOn || [] + + ;(pageResponse.mergeProps || []).forEach((prop) => mergeProp(pageResponse, currentProps, prop, true, matchPropsOn)) + ;(pageResponse.prependProps || []).forEach((prop) => mergeProp(pageResponse, currentProps, prop, false, matchPropsOn)) + ;(pageResponse.deepMergeProps || []).forEach((prop) => { + const currentProp = get(currentProps, prop) + const incomingProp = get(pageResponse.props, prop) + + set(pageResponse.props, prop, deepMergeProp(currentProp, incomingProp, prop, matchPropsOn)) + }) -const queue = new Queue>() + const nestedTopKeys = new Set(nestedProps.filter((prop) => prop.includes('.')).map((prop) => prop.split('.')[0])) + + for (const key of nestedTopKeys) { + const currentValue = currentProps[key] + + if (isObject(currentValue) && isObject(pageResponse.props[key])) { + pageResponse.props[key] = deepMergeObjects(currentValue as PageProps, pageResponse.props[key] as PageProps) + } + } + + pageResponse.props = { ...currentProps, ...pageResponse.props } +} export class Response { protected wasPrefetched = false @@ -31,10 +295,16 @@ export class Response { protected requestParams: RequestParams, protected response: HttpResponse, protected originatingPage: Page, + protected capturedBase: BaseSnapshot, ) {} - public static create(params: RequestParams, response: HttpResponse, originatingPage: Page): Response { - return new Response(params, response, originatingPage) + public static create( + params: RequestParams, + response: HttpResponse, + originatingPage: Page, + capturedBase: BaseSnapshot, + ): Response { + return new Response(params, response, originatingPage, capturedBase) } public isProcessed(): boolean { @@ -48,7 +318,7 @@ export class Response { } public async handle() { - return queue.add(() => this.process()) + return responseQueue.add(() => this.process()) } public async process() { @@ -76,35 +346,70 @@ export class Response { } if (this.requestParams.all().onHttpException(response) === false) { + this.closeLayerAttempt() return } if (!fireHttpExceptionEvent(response)) { + this.closeLayerAttempt() return } } + const data = this.getDataFromResponse(this.response.data) + + if (data && data.close) { + const { layerId } = this.requestParams.all() + // A close is never installed: it closes the layer the visit was made from, or the top one when + // it named none. One whose layer has gone since has nothing of its own left to close. + const closes = layerId === undefined ? layersOf(currentPage.get()).at(-1) : layerAt(currentPage.get(), layerId) + + if (closes) { + // Not awaited: the close refreshes what it lands on, and that response queues behind this one. + router.close(closes.id) + } else { + router.reload() + } + + this.closeLayerAttempt() + + router.flushByCacheTags(this.requestParams.all().invalidateCacheTags || []) + + // The visit succeeded even though nothing installed. The closing layers are still on screen + // running their exit, so the page the callbacks see is the one the close leaves behind. + const landsOn = layerClosing.withoutClosingLayers(currentPage.get()) + + fireSuccessEvent(landsOn, { visitId: this.requestParams.all().id }) + + return this.requestParams.all().onSuccess(landsOn) + } + await history.processQueue() history.preserveUrl = this.requestParams.all().preserveUrl - await this.setPage() + const landedOn = await this.setPage() - const { flash } = currentPage.get() + const page = currentPage.get() + const tier = landedOn === undefined ? page : layerAt(page, landedOn) - if (Object.keys(flash).length > 0 && !this.requestParams.isDeferredPropsRequest()) { - fireFlashEvent(flash) - this.requestParams.all().onFlash(flash) - } + if (tier) { + const { flash } = tier - const errors = currentPage.get().props.errors || {} + if (Object.keys(flash).length > 0 && !this.requestParams.isDeferredPropsRequest()) { + fireFlashEvent(flash) + this.requestParams.all().onFlash(flash) + } + + const errors = (tier.props.errors || {}) as Errors & ErrorBag - if (Object.keys(errors).length > 0) { - const scopedErrors = this.getScopedErrors(errors) + if (Object.keys(errors).length > 0) { + const scopedErrors = this.getScopedErrors(errors) - fireErrorEvent(scopedErrors, { page: currentPage.get(), visitId: this.requestParams.all().id }) + fireErrorEvent(scopedErrors, { page, visitId: this.requestParams.all().id }) - return this.requestParams.all().onError(scopedErrors) + return this.requestParams.all().onError(scopedErrors) + } } router.flushByCacheTags(this.requestParams.all().invalidateCacheTags || []) @@ -112,7 +417,7 @@ export class Response { if (!this.wasPrefetched) { // We end up here other than from the prefetch cache, so we assume this response is // newer than the cached one and therefore flush the cache. - router.flush(currentPage.get().url) + router.flush(tier?.url ?? page.url) } fireSuccessEvent(currentPage.get(), { visitId: this.requestParams.all().id }) @@ -126,6 +431,10 @@ export class Response { this.requestParams.merge(params) } + public setCapturedBase(capturedBase: BaseSnapshot): void { + this.capturedBase = capturedBase + } + public getPageResponse(): Page { const data = this.getDataFromResponse(this.response.data) @@ -138,9 +447,15 @@ export class Response { } protected async handleNonInertiaResponse() { + // Nothing below installs a layer: every path either navigates away or shows the error. + this.closeLayerAttempt() + if (this.isInertiaRedirect()) { + // The layer target is dropped, so a redirected layer reload lands as a base navigation. + const { layerId, layerOwner, ...params } = this.requestParams.all() + router.visit(this.getHeader('x-inertia-redirect'), { - ...this.requestParams.all(), + ...params, method: 'get', data: {}, }) @@ -201,7 +516,7 @@ export class Response { /** * @link https://inertiajs.com/redirects#external-redirects */ - protected locationVisit(url: URL): boolean | void { + protected locationVisit(url: URL): Promise | boolean | void { try { if (typeof window === 'undefined') { return @@ -218,6 +533,10 @@ export class Response { // force a full-page navigation the user never initiated. The next user-initiated visit // hits the same location response and reloads then. if (versionChange && this.requestParams.all().async) { + if (this.requestParams.isWalkRequest()) { + return landWalk(this.capturedBase.generation) + } + return } @@ -235,17 +554,21 @@ export class Response { } } - protected async setPage(): Promise { + protected async setPage(): Promise { + // Composing onto a stack the browser is stepping out of would reopen what the user just closed. + await layerClosing.unwindSettled() + const pageResponse = this.getPageResponse() if (!this.shouldSetPage(pageResponse)) { - return Promise.resolve() + this.closeLayerAttempt() + return undefined } this.response = await interceptors.processResponse(this.requestParams.all(), this.response) this.mergeProps(pageResponse) - currentPage.mergeOncePropsIntoResponse(pageResponse) + currentPage.mergeOncePropsIntoResponse(pageResponse, { layerId: this.requestParams.all().layerId }) this.preserveOptimisticProps(pageResponse) this.preserveEqualProps(pageResponse) @@ -253,19 +576,185 @@ export class Response { this.requestParams.setPreserveOptions(pageResponse) - pageResponse.url = history.preserveUrl ? currentPage.get().url : this.pageUrl(pageResponse) + // `preserveUrl` puts the base's url on the response, so the layer's own is held here. + const responseUrl = this.pageUrl(pageResponse) + + pageResponse.url = history.preserveUrl ? currentPage.get().url : responseUrl + + let base = currentPage.get() + const isLayer = isLayerResponse(pageResponse) + const walksBeneath = this.requestParams.isWalkRequest() + + const handedBackTo = this.errorsHandedBackTo(pageResponse) + const refreshesBase = this.refreshesBase(pageResponse) || handedBackTo !== undefined + const keepsStack = (this.keepsStack(pageResponse) || handedBackTo !== undefined) && layersOf(base).length > 0 + + if (!isLayer && !walksBeneath) { + this.carryOrEndDetour(pageResponse) + } + + // A carried return composes over the base its stash holds, which the clauses below cannot see. + const carry = interstitialCarryFor(responseUrl) + const composesAsLayer = + !walksBeneath && isLayer && (carry !== undefined || this.hasValidCapturedBase(pageResponse, responseUrl, base)) + const carriedReturn = composesAsLayer && carry !== undefined + + if (composesAsLayer) { + // A link four layers deep can name a layer that is not the top one, so the stack closes down + // to it before the response lands. A refresh in place names no level: a poll or a deferred + // group filling in under an open layer leaves that layer where it is. Neither does a form + // handed back to a layer standing above the address it was submitted from. + if (!this.refreshesInPlace() && handedBackTo === undefined) { + await layerClosing.closeAbove(openLayerFor(base, pageResponse, this.requestParams.all().layerId)?.id) + + base = currentPage.get() + } + + const open = openLayerFor(base, pageResponse, this.requestParams.all().layerId) + + if (open && this.requestParams.isPartial() && open.component === pageResponse.component) { + this.mergeIntoTier(pageResponse, open) + } + } + + const landing = landLayerResponse({ + base, + response: pageResponse, + responseUrl, + composesAsLayer, + walksBeneath, + // The layer's own component is kept only when the visit asked for it, as the base's is. + remount: !this.requestParams.all().preserveState, + carried: carry?.layer ?? this.carriedLayer(base), + preserveUrl: history.preserveUrl, + keepsStack, + }) + + if (handedBackTo) { + const landedOn = tierOf(landing.page, landing.landedOn) + + landing.page = withTier( + withTier(landing.page, landing.landedOn, { props: { ...landedOn.props, errors: {} }, flash: {} }), + handedBackTo.id, + { props: { ...handedBackTo.props, errors: pageResponse.props.errors }, flash: landedOn.flash }, + ) + landing.landedOn = handedBackTo.id + } - this.requestParams.all().onBeforeUpdate(pageResponse) - fireBeforeUpdateEvent(pageResponse) + const page = landing.page + const landsOverTheStack = composesAsLayer || handedBackTo !== undefined - return currentPage.set(pageResponse, { - replace: this.requestParams.all().replace, - preserveScroll: this.requestParams.all().preserveScroll as boolean, - preserveState: this.requestParams.all().preserveState as boolean, + history.preserveUrl = landing.preserveUrl + + if (carriedReturn) { + interstitialCarry = undefined + } + + if (landing.landedOn === undefined) { + // The open never landed, so its handle is spent. Fired ahead of the page that replaces it. + this.closeLayerAttempt() + } + + this.requestParams.all().onBeforeUpdate(page) + fireBeforeUpdateEvent(page) + + await currentPage.set(page, { + // The layer's entry takes the prompt's place, so back lands on the base, not a dead prompt. + replace: carriedReturn || this.requestParams.all().replace, + preserveScroll: landsOverTheStack || (this.requestParams.all().preserveScroll as boolean), + preserveState: landsOverTheStack || (this.requestParams.all().preserveState as boolean), + preservesBase: landsOverTheStack || walksBeneath || refreshesBase, viewTransition: this.requestParams.all().viewTransition, cached: this.requestParams.all().cached, visitId: this.requestParams.all().id, }) + + if (landing.walksTo !== undefined) { + walkTo(landing.walksTo) + } else if (walksBeneath) { + // The write can be superseded while its component resolves, by a close's own write say, and + // what superseded it still shows the blank. This response is spent, so the walk is sent again. + const base = loadingBase(currentPage.get()) + + if (base !== undefined) { + walkTo(base) + } + } + + return landing.landedOn + } + + // A marked non-layer response is a prompt the user will return from, so the base it was dispatched + // from is stashed before the prompt installs. The mark alone says so: a plain visit the server + // answers with a layer once the prompt is done is the same detour, and the client has no layer of + // its own to recognise it by. An unmarked landing ends any pending detour. + protected carryOrEndDetour(pageResponse: Page): void { + const { url, layerOwner } = this.requestParams.all() + + // A prompt answering its own submit, a wrong password say, is the detour already running. Only + // an open of its own starts a second one, retiring the attempt the old stash was pausing. + if (pageResponse.interstitial && interstitialCarry && layerOwner === undefined) { + return + } + + if (interstitialCarry) { + // The detour it was holding is not coming back, so the attempt it paused is spent after all. + closeUnlandedLayer(currentPage.get(), interstitialCarry.layer.layerId) + } + + interstitialCarry = pageResponse.interstitial + ? { layer: this.carriedLayer(this.capturedBase.page), dispatchedUrl: urlWithoutHash(url).href } + : undefined + } + + protected carriedLayer(base: Page): CarriedLayer { + const { layerId, layerOwner } = this.requestParams.all() + + return { + base, + layerId, + opening: layerOwner !== undefined, + owner: layerOwner ?? layerAt(base, layerId)?.id ?? currentPage.id(), + claims: this.requestParams.fabricatedLayer(), + } + } + + protected closeLayerAttempt(): void { + const { layerId } = this.requestParams.all() + + // A marked detour returns to this layer, so its attempt is paused rather than spent. + if (interstitialCarry?.layer.layerId === layerId) { + return + } + + closeUnlandedLayer(currentPage.get(), layerId) + } + + protected hasValidCapturedBase(pageResponse: Page, layerUrl: string, base: Page): boolean { + const { layerId, layerOwner } = this.requestParams.all() + + return capturedBaseIsValid({ + captured: this.capturedBase, + live: { page: base, generation: currentPage.generation() }, + dispatchedUrl: this.requestParams.all().url, + dispatchedFrom: layerOwner ?? layerId, + opening: layerOwner !== undefined, + layer: { url: layerUrl, key: layerKeyOf(pageResponse), base: layerBaseOf(pageResponse) }, + }) + } + + // A layer response for a visit aimed at a layer that has since closed has nowhere to land: it + // would either reopen what the user dismissed or, having no valid base left, tear the page down + // to walk one back. An open carries no tier yet, so it is exempt. + protected answersAClosedLayer(pageResponse: Page): boolean { + const { layerId, layerOwner } = this.requestParams.all() + + return ( + layerId !== undefined && + layerOwner === undefined && + !layerAt(currentPage.get(), layerId) && + isLayerResponse(pageResponse) + ) } protected getDataFromResponse(response: any): any { @@ -281,12 +770,25 @@ export class Response { } protected shouldSetPage(pageResponse: Page): boolean { + if (this.requestParams.isWalkRequest()) { + return this.capturedBase.generation === currentPage.generation() + } + + if (this.answersAClosedLayer(pageResponse)) { + return false + } + if (!this.requestParams.all().async) { // If the request is sync, we should always set the page return true } - if (this.originatingPage.component !== pageResponse.component) { + // Read against the tier the request was aimed at: what says a layer has moved on is its own + // component and url changing, neither of which the page beneath it carries. + const originating = tierOf(this.originatingPage, this.requestParams.all().layerId) + const live = tierOf(currentPage.get(), this.requestParams.all().layerId) + + if (originating.component !== pageResponse.component) { // We originated from a component but the response re-directed us, // we should respect the redirection and set the page return true @@ -294,16 +796,20 @@ export class Response { // At this point, if the originating request component is different than the current component, // the user has since navigated and we should discard the response - if (this.originatingPage.component !== currentPage.get().component) { + if (originating.component !== live.component) { return false } - const originatingUrl = hrefToUrl(this.originatingPage.url) - const currentPageUrl = hrefToUrl(currentPage.get().url) + if (originating.url === null || live.url === null) { + return originating.url === live.url + } + + const originatingUrl = hrefToUrl(originating.url) + const liveUrl = hrefToUrl(live.url) // We have the same component, let's double-check the URL // If we're no longer on the same path name (e.g. /users/1 -> /users/2), we should not set the page - return originatingUrl.origin === currentPageUrl.origin && originatingUrl.pathname === currentPageUrl.pathname + return originatingUrl.origin === liveUrl.origin && originatingUrl.pathname === liveUrl.pathname } protected pageUrl(pageResponse: Page) { @@ -323,169 +829,130 @@ export class Response { return } + const layer = isLayerResponse(pageResponse) + ? openLayerFor(currentPage.get(), pageResponse, this.requestParams.all().layerId) + : undefined + for (const key of Object.keys(pageResponse.props)) { - if (currentPage.hasBaseline(key)) { - currentPage.updateBaseline(key, pageResponse.props[key]) - pageResponse.props[key] = currentPage.get().props[key] + if (currentPage.hasBaseline(key, layer?.id)) { + currentPage.updateBaseline(key, pageResponse.props[key], layer?.id) + pageResponse.props[key] = (layer ? layer.props : currentPage.get().props)[key] } } } + // Read against the tier the response is landing on: a layer's props have nothing in common with + // the base's, so sharing what happens to be equal between them would be sharing across tiers. protected preserveEqualProps(pageResponse: Page): void { - if (pageResponse.component !== currentPage.get().component) { + const tier = isLayerResponse(pageResponse) + ? openLayerFor(currentPage.get(), pageResponse, this.requestParams.all().layerId) + : currentPage.get() + + if (!tier || tier.component !== pageResponse.component) { return } - const currentPageProps = currentPage.get().props - Object.entries(pageResponse.props).forEach(([key, value]) => { - if (isEqual(value, currentPageProps[key])) { - pageResponse.props[key] = currentPageProps[key] + if (isEqual(value, tier.props[key])) { + pageResponse.props[key] = tier.props[key] } }) } - protected mergeProps(pageResponse: Page): void { - if (!this.requestParams.isPartial() || pageResponse.component !== currentPage.get().component) { - return - } + protected refreshesInPlace(): boolean { + return this.requestParams.isPartial() || this.requestParams.isReload() + } - const propsToAppend = pageResponse.mergeProps || [] - const propsToPrepend = pageResponse.prependProps || [] - const propsToDeepMerge = pageResponse.deepMergeProps || [] - const matchPropsOn = pageResponse.matchPropsOn || [] - - const mergeProp = (prop: string, shouldAppend: boolean) => { - const currentProp = get(currentPage.get().props, prop) - const incomingProp = get(pageResponse.props, prop) - - if (Array.isArray(incomingProp)) { - const newArray = this.mergeOrMatchItems( - (currentProp || []) as any[], - incomingProp, - prop, - matchPropsOn, - shouldAppend, - ) - - set(pageResponse.props, prop, newArray) - } else if (typeof incomingProp === 'object' && incomingProp !== null) { - const newObject = { - ...(currentProp || {}), - ...incomingProp, - } + // The stack stands over the refreshed base. A blank base takes whatever fills it in, being a + // placeholder a walk left. + protected keepsStack(pageResponse: Page): boolean { + return this.refreshesInPlace() && (this.staysOnBaseComponent(pageResponse) || currentPage.get().component === '') + } - set(pageResponse.props, prop, newObject) - } - } + protected refreshesBase(pageResponse: Page): boolean { + return this.refreshesInPlace() && this.landsOnTheBase(pageResponse) + } - propsToAppend.forEach((prop) => mergeProp(prop, true)) - propsToPrepend.forEach((prop) => mergeProp(prop, false)) + protected landsOnTheBase(pageResponse: Page): boolean { + return ( + this.staysOnBaseComponent(pageResponse) && + isSameUrlWithoutHash(hrefToUrl(pageResponse.url), hrefToUrl(currentPage.get().url)) + ) + } - propsToDeepMerge.forEach((prop) => { - const currentProp = get(currentPage.get().props, prop) - const incomingProp = get(pageResponse.props, prop) + // A layer that does not own the address, one with no url of its own say, is submitted from the + // address of the tier beneath it, so that is where its errors and flash come back. A response arriving there + // unchanged is the server handing the form back, not a navigation. + protected errorsHandedBackTo(pageResponse: Page): LayerState | undefined { + const page = currentPage.get() + const layer = layerAt(page, this.requestParams.all().layerId) + const address = addressTierOf(page) - // Function to recursively merge objects and arrays - const deepMerge = (target: any, source: any, matchProp: string) => { - if (Array.isArray(source)) { - return this.mergeOrMatchItems(target, source, matchProp, matchPropsOn) - } + return layer && + address !== layer && + Object.keys(pageResponse.props.errors ?? {}).length > 0 && + pageResponse.component === address.component && + isSameUrlWithoutHash(hrefToUrl(pageResponse.url), hrefToUrl(addressOf(page))) + ? layer + : undefined + } - if (typeof source === 'object' && source !== null) { - // Merge objects by iterating over keys - return Object.keys(source).reduce( - (acc, key) => { - acc[key] = deepMerge(target ? target[key] : undefined, source[key], `${matchProp}.${key}`) - return acc - }, - { ...target }, - ) - } + protected staysOnBaseComponent(pageResponse: Page): boolean { + return pageResponse.component === currentPage.get().component + } - // If the source is neither an array nor an object, simply return it - return source - } + protected mergeProps(pageResponse: Page): void { + if (!this.requestParams.isPartial() || !this.staysOnBaseComponent(pageResponse) || isLayerResponse(pageResponse)) { + return + } - // Apply the deep merge and update the page response - set(pageResponse.props, prop, deepMerge(currentProp, incomingProp, prop)) - }) + this.mergeIntoTier(pageResponse, currentPage.get()) + } - const nestedTopKeys = new Set( - [...this.requestParams.all().only, ...this.requestParams.all().except] - .filter((prop) => prop.includes('.')) - .map((prop) => prop.split('.')[0]), - ) - for (const key of nestedTopKeys) { - const currentValue = currentPage.get().props[key] + protected mergeIntoTier(pageResponse: Page, tier: Layer): void { + mergePropsInto(pageResponse, tier.props, this.nestedProps()) - if (this.isObject(currentValue) && this.isObject(pageResponse.props[key])) { - pageResponse.props[key] = this.deepMergeObjects(currentValue as PageProps, pageResponse.props[key] as PageProps) - } + if (this.shouldPreserveErrors(pageResponse, tier)) { + pageResponse.props.errors = tier.props.errors as Errors & ErrorBag } - pageResponse.props = { ...currentPage.get().props, ...pageResponse.props } - - if (this.shouldPreserveErrors(pageResponse)) { - pageResponse.props.errors = currentPage.get().props.errors + if (tier.scrollProps) { + pageResponse.scrollProps = { ...tier.scrollProps, ...pageResponse.scrollProps } } - // Preserve the existing scrollProps - if (currentPage.get().scrollProps) { - pageResponse.scrollProps = { - ...(currentPage.get().scrollProps || {}), - ...(pageResponse.scrollProps || {}), - } + if (Object.keys(tier.onceProps ?? {}).length > 0) { + pageResponse.onceProps = { ...tier.onceProps, ...pageResponse.onceProps } } - // Preserve the existing onceProps - if (currentPage.hasOnceProps()) { - pageResponse.onceProps = { - ...(currentPage.get().onceProps || {}), - ...(pageResponse.onceProps || {}), - } + if (Object.keys(tier.initialDeferredProps ?? {}).length > 0) { + pageResponse.initialDeferredProps = tier.initialDeferredProps } - // Preserve flash data on deferred props requests (background fetches that replay - // the same flash from the initial load), but let regular partial reloads use - // whatever the server sent (which may be empty, clearing stale flash) if (this.requestParams.isDeferredPropsRequest()) { - pageResponse.flash = { ...currentPage.get().flash } - } - - const currentOriginalDeferred = currentPage.get().initialDeferredProps - if (currentOriginalDeferred && Object.keys(currentOriginalDeferred).length > 0) { - pageResponse.initialDeferredProps = currentOriginalDeferred + pageResponse.flash = { ...tier.flash } } - pageResponse.rescuedProps = this.mergeRescuedProps(pageResponse) + pageResponse.rescuedProps = this.mergedRescued(pageResponse, tier.rescuedProps) } - protected mergeRescuedProps(pageResponse: Page): string[] { - const currentRescued = currentPage.get().rescuedProps ?? [] - const incomingRescued = pageResponse.rescuedProps ?? [] + protected nestedProps(): string[] { + return [...this.requestParams.all().only, ...this.requestParams.all().except] + } - const newRescued = new Set( - currentRescued.filter((prop) => !partialReloadRequestsProp(this.requestParams.all(), prop)), - ) + protected mergedRescued(pageResponse: Page, current: string[] = []): string[] { + const rescued = new Set(current.filter((prop) => !partialReloadRequestsProp(this.requestParams.all(), prop))) - incomingRescued.forEach((prop) => newRescued.add(prop)) + ;(pageResponse.rescuedProps ?? []).forEach((prop) => rescued.add(prop)) - return Array.from(newRescued) + return Array.from(rescued) } - /** - * By default, the Laravel adapter shares validation errors via Inertia::always(), - * so responses always include errors, even when empty. Components like - * InfiniteScroll and WhenVisible, as well as loading deferred props, - * perform async requests that should practically never reset errors. - */ - protected shouldPreserveErrors(pageResponse: Page): boolean { + protected shouldPreserveErrors(pageResponse: Page, tier: Layer): boolean { if (!this.requestParams.all().preserveErrors) { return false } - const currentErrors = currentPage.get().props.errors + const currentErrors = tier.props.errors as Errors | undefined if (!currentErrors || Object.keys(currentErrors).length === 0) { return false @@ -500,117 +967,6 @@ export class Response { return true } - protected isObject(item: any): boolean { - return item && typeof item === 'object' && !Array.isArray(item) - } - - protected deepMergeObjects(target: PageProps, source: PageProps): PageProps { - const result = { ...target } - - for (const key of Object.keys(source)) { - const targetValue = target[key] - const sourceValue = source[key] - - if (this.isObject(targetValue) && this.isObject(sourceValue)) { - result[key] = this.deepMergeObjects(targetValue as PageProps, sourceValue as PageProps) - } else { - result[key] = sourceValue - } - } - - return result - } - - protected mergeOrMatchItems( - existingItems: any[], - newItems: any[], - matchProp: string, - matchPropsOn: string[], - shouldAppend = true, - ) { - const items = Array.isArray(existingItems) ? existingItems : [] - - // Find the matching key for this specific property path - const matchingKey = matchPropsOn.find((key) => { - const keyPath = key.split('.').slice(0, -1).join('.') - - return keyPath === matchProp - }) - - // If no matching key is configured, simply concatenate the arrays - if (!matchingKey) { - return shouldAppend ? [...items, ...newItems] : [...newItems, ...items] - } - - // Extract the property name we'll use to match items (e.g., 'id' from 'users.data.id') - const uniqueProperty = matchingKey.split('.').pop() || '' - - // Create a map of new items by their unique property lookups - const newItemsMap = new Map() - - newItems.forEach((item) => { - if (this.hasUniqueProperty(item, uniqueProperty)) { - newItemsMap.set(item[uniqueProperty], item) - } - }) - - return shouldAppend - ? this.appendWithMatching(items, newItems, newItemsMap, uniqueProperty) - : this.prependWithMatching(items, newItems, newItemsMap, uniqueProperty) - } - - protected appendWithMatching( - existingItems: any[], - newItems: any[], - newItemsMap: Map, - uniqueProperty: string, - ): any[] { - // Update existing items with new values, keep non-matching items - const updatedExisting = existingItems.map((item) => { - if (this.hasUniqueProperty(item, uniqueProperty) && newItemsMap.has(item[uniqueProperty])) { - return newItemsMap.get(item[uniqueProperty]) - } - - return item - }) - - // Filter new items to only include those not already in existing items - const newItemsToAdd = newItems.filter((item) => { - if (!this.hasUniqueProperty(item, uniqueProperty)) { - return true // Always add items without unique property - } - - return !existingItems.some( - (existing) => - this.hasUniqueProperty(existing, uniqueProperty) && existing[uniqueProperty] === item[uniqueProperty], - ) - }) - - return [...updatedExisting, ...newItemsToAdd] - } - - protected prependWithMatching( - existingItems: any[], - newItems: any[], - newItemsMap: Map, - uniqueProperty: string, - ): any[] { - // Filter existing items, keeping only those not being updated - const untouchedExisting = existingItems.filter((item) => { - if (this.hasUniqueProperty(item, uniqueProperty)) { - return !newItemsMap.has(item[uniqueProperty]) - } - - return true - }) - - return [...newItems, ...untouchedExisting] - } - - protected hasUniqueProperty(item: any, property: string): boolean { - return item && typeof item === 'object' && property in item - } - protected async setRememberedState(pageResponse: Page): Promise { const rememberedState = await history.getState(history.rememberedState, {}) diff --git a/packages/core/src/router.ts b/packages/core/src/router.ts index ecfc7e523..91c2280e6 100644 --- a/packages/core/src/router.ts +++ b/packages/core/src/router.ts @@ -1,11 +1,30 @@ import { cloneDeep, isEqual } from 'es-toolkit' import { get, set } from 'es-toolkit/compat' -import { progress } from '.' +import { progress, router } from '.' import { config } from './config' import { eventHandler } from './eventHandler' import { fireBeforeEvent, fireClientVisitEvent, fireFlashEvent } from './events' import { history } from './history' import { InitialVisit } from './initialVisit' +import { + LayerHandle, + closeUnlandedLayer, + composeLayer, + composeLocalLayer, + createLayerHandle, + isLocalLayer, + layerAt, + layerClosing, + layerHandleFor, + layerPageOf, + layersOf, + nextLayerId, + nextRenderKey, + registryHas, + registryWrite, + tierOf, + withTier, +} from './layers' import { setPathPreservingIdentity, stripTopLevelUndefined } from './objectUtils' import { page as currentPage } from './page' import { polls } from './polls' @@ -17,13 +36,19 @@ import { RequestStream } from './requestStream' import { Scroll } from './scroll' import { ActiveVisit, + BaseSnapshot, ClientSideVisitOptions, Component, + ErrorBag, + Errors, FlashData, GlobalEvent, GlobalEventNames, GlobalEventResult, InFlightPrefetch, + Layer, + LayerState, + LocalLayer, Method, OptimisticCallback, Page, @@ -53,30 +78,39 @@ import { const noop = () => {} -export class Router { - protected syncRequestStream = new RequestStream({ - maxConcurrent: 1, - interruptible: true, - }) +const syncRequests = new RequestStream({ + maxConcurrent: 1, + interruptible: true, +}) - protected asyncRequestStream = new RequestStream({ - maxConcurrent: Infinity, - interruptible: false, - }) +const asyncRequests = new RequestStream({ + maxConcurrent: Infinity, + interruptible: false, +}) - protected clientVisitQueue = new Queue>() +const clientVisits = new Queue>() + +export class Router { + protected syncRequestStream = syncRequests + protected asyncRequestStream = asyncRequests + protected clientVisitQueue = clientVisits protected pendingOptimisticCallback: OptimisticCallback | undefined = undefined + // Bound to a layer, every request this router makes lands on it. The app's own is bound to none. + constructor(protected layerId?: string) {} + public init({ initialPage, resolveComponent, + resolveLoading, swapComponent, onFlash, }: RouterInitParams): void { currentPage.init({ initialPage, resolveComponent, + resolveLoading, swapComponent, onFlash, }) @@ -91,8 +125,8 @@ export class Router { } }) - eventHandler.on('loadDeferredProps', (deferredProps: Page['deferredProps']) => { - this.loadDeferredProps(deferredProps) + eventHandler.on('loadDeferredProps', (payload: { deferredProps: Page['deferredProps']; layerId?: string }) => { + this.loadDeferredProps(payload.deferredProps, payload.layerId) }) eventHandler.on('historyQuotaExceeded', (url) => { @@ -159,8 +193,16 @@ export class Router { return } - return this.visit(window.location.href, { - ...options, + const page = currentPage.get() + const { layerId = this.layerId, ...reloadOptions } = options + const layer = layerAt(page, layerId) + + const url = layer?.url ?? ((page.layers?.length ?? 0) > 0 ? page.url : window.location.href) + + const visitOptions: VisitOptions & { reload: true } = { + ...reloadOptions, + ...(layer?.url ? { layerId } : {}), + reload: true, preserveScroll: true, preserveState: true, async: true, @@ -168,37 +210,88 @@ export class Router { ...(options.headers || {}), 'Cache-Control': 'no-cache', }, + } + + this.dispatchVisit(url, visitOptions) + } + + public layer( + url: string | URL | UrlMethodPair, + options?: VisitOptions, + ): LayerHandle + public layer(local: LocalLayer): LayerHandle + public layer( + target: string | URL | UrlMethodPair | LocalLayer, + options: VisitOptions = {}, + ): LayerHandle { + if (isLocalLayer(target)) { + return this.openLayer((id, owner) => + this.clientVisitQueue.add(() => this.performLocalOpen(id, owner, target.component, target.props ?? {})), + ) + } + + return this.openLayer((id, owner) => this.visit(target, { ...options, layerId: id, layerOwner: owner })) + } + + protected openLayer(open: (id: string, owner: string) => void): LayerHandle { + const id = nextLayerId(currentPage.get()) + const handle = this.createLayerHandleWithOwner(id) + + registryWrite(id, handle) + + open(id, this.layerId ?? layersOf(currentPage.get()).at(-1)?.id ?? currentPage.id()) + + if (!registryHas(id)) { + // Refused before the caller had the handle to subscribe on, so its onClose is owed a turn. + queueMicrotask(() => handle.fireOnClose()) + } + + return handle + } + + public layerHandle(id?: string): LayerHandle { + return layerHandleFor(id ?? currentPage.id(), (handleId) => this.createLayerHandleWithOwner(handleId)) + } + + protected createLayerHandleWithOwner(id: string): LayerHandle { + return createLayerHandle( + id, + (handleId) => this.closeLayer(handleId), + (layerId) => layerAt(currentPage.get(), layerId)?.owner, + ) + } + + protected async performLocalOpen(id: string, owner: string, component: string, props: PageProps): Promise { + await layerClosing.unwindSettled() + + await currentPage.set(composeLocalLayer(currentPage.get(), component, props, id, owner), { + preserveScroll: true, + preserveState: true, + preservesBase: true, + viewTransition: false, }) } - public remember(data: unknown, key = 'default'): void { - history.remember(data, key) + public remember(data: unknown, key = 'default', layerId?: string): void { + history.remember(data, key, layerId) } - public restore(key = 'default'): T | undefined { - return history.restore(key) as T | undefined + public restore(key = 'default', layerId?: string): T | undefined { + return history.restore(key, layerId) as T | undefined } public on( type: TEventName, callback: (event: GlobalEvent) => GlobalEventResult, ): VoidFunction { - if (typeof window === 'undefined') { - return () => {} - } - - return eventHandler.onGlobalEvent(type, callback) + return this.onGlobal(type, callback) } public once( type: TEventName, callback: (event: GlobalEvent) => GlobalEventResult, ): VoidFunction { - if (typeof window === 'undefined') { - return () => {} - } - - const remove = this.on(type, (event) => { + const remove = this.onGlobal(type, (event) => { remove() return callback(event) }) @@ -206,6 +299,19 @@ export class Router { return remove } + // A `LayerApi` is this router with its own `on()` for the layer's events put on the instance, so + // the router's own callers come through here to reach Inertia's. + protected onGlobal( + type: TEventName, + callback: (event: GlobalEvent) => GlobalEventResult, + ): VoidFunction { + if (typeof window === 'undefined') { + return () => {} + } + + return eventHandler.onGlobalEvent(type, callback) + } + public hasPendingOptimistic(): boolean { return this.asyncRequestStream.hasPendingOptimistic() } @@ -256,6 +362,14 @@ export class Router { href: string | URL | UrlMethodPair, options: VisitOptions = {}, ): void { + this.dispatchVisit(href, { ...options, layerId: options.layerId ?? this.layerId }) + } + + // `visit` with the tier already settled: a reload that dropped its layer keeps it dropped. + protected dispatchVisit( + href: string | URL | UrlMethodPair, + options: VisitOptions = {}, + ): boolean { options.optimistic = options.optimistic ?? this.pendingOptimisticCallback this.pendingOptimisticCallback = undefined @@ -272,17 +386,24 @@ export class Router { // If either of these return false, we don't want to continue if (events.onBefore(visit) === false || !fireBeforeEvent(visit)) { - return + closeUnlandedLayer(currentPage.get(), visit.layerId) + + return false } + const capturedBase = this.captureBase() + const currentPageUrl = hrefToUrl(currentPage.get().url) const isPartialReload = visit.only.length > 0 || visit.except.length > 0 || visit.reset.length > 0 + const targetedLayer = layerAt(currentPage.get(), visit.layerId) + const tierUrl = targetedLayer?.url ? hrefToUrl(targetedLayer.url) : currentPageUrl + // 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) + ? isSameUrlWithoutQueryOrHash(visit.url, tierUrl) + : isSameUrlWithoutHash(visit.url, tierUrl) if (!isSamePage) { // Cancel in-flight requests aimed at the page we're navigating away from @@ -292,7 +413,8 @@ export class Router { (request) => !request.isPrefetch() && !request.isOptimistic() && - isSameUrlWithoutQueryOrHash(request.getUrl(), currentPageUrl), + (request.layerId ?? null) === (visit.layerId ?? null) && + isSameUrlWithoutQueryOrHash(request.getUrl(), tierUrl), ) } @@ -303,7 +425,7 @@ export class Router { } if (options.optimistic) { - this.applyOptimisticUpdate(options.optimistic, events) + this.applyOptimisticUpdate(options.optimistic, events, visit.layerId) } if (!currentPage.isCleared() && !visit.preserveUrl) { @@ -311,7 +433,7 @@ export class Router { Scroll.save() } - const requestParams: PendingVisit & VisitCallbacks = { + const requestParams: PendingVisit & VisitCallbacks & { fabricatedLayer?: boolean } = { ...visit, ...events, } @@ -321,11 +443,13 @@ export class Router { if (prefetched) { progress.reveal(prefetched.inFlight) - prefetchedRequests.use(prefetched, requestParams) + prefetchedRequests.use(prefetched, requestParams, capturedBase) } else { progress.reveal(true) const requestStream = visit.async ? this.asyncRequestStream : this.syncRequestStream - requestStream.send(Request.create(requestParams, currentPage.get(), { optimistic: !!options.optimistic })) + requestStream.send( + Request.create(requestParams, currentPage.get(), capturedBase, { optimistic: !!options.optimistic }), + ) } } @@ -337,18 +461,21 @@ export class Router { } if (visit.component) { - history.processQueue().then(() => { - this.performInstantSwap(visit).then(() => { + Promise.all([history.processQueue(), layerClosing.unwindSettled()]).then(() => { + this.performInstantSwap(visit).then((fabricatedLayer) => { requestParams.preserveScroll = true requestParams.preserveState = true requestParams.replace = true requestParams.viewTransition = false + requestParams.fabricatedLayer = fabricatedLayer sendRequest() }) }) } else { sendRequest() } + + return true } public getCached( @@ -415,7 +542,7 @@ export class Router { this.asyncRequestStream.interruptInFlight() - const requestParams: PendingVisit & VisitCallbacks = { + const requestParams: PendingVisit & VisitCallbacks & { fabricatedLayer?: boolean } = { ...visit, ...events, } @@ -438,7 +565,7 @@ export class Router { prefetchedRequests.add( requestParams, (params) => { - this.asyncRequestStream.send(Request.create(params, currentPage.get())) + this.asyncRequestStream.send(Request.create(params, currentPage.get(), this.captureBase())) }, { cacheFor: config.get('prefetch.cacheFor'), @@ -449,6 +576,43 @@ export class Router { }) } + public close(id?: string): Promise { + return this.closeLayer(id) + } + + // A `LayerApi` puts its own no-argument `close()` on the instance, so the router's own callers + // come through here to close the layer they name rather than the one the api is bound to. + protected closeLayer(id?: string): Promise { + return layerClosing.close(id, { + programmatic: true, + refresh: (address, layerId) => this.refreshBeneath(address, layerId), + }) + } + + public closed(id?: string): Promise { + return layerClosing.closed(id) + } + + protected refreshBeneath(address: string, layerId?: string): Promise { + return new Promise((resolve) => { + const reload: VisitOptions & { reload: true } = { + reload: true, + layerId, + preserveState: true, + preserveScroll: true, + replace: true, + // A follow-up, not a navigation: sent sync it would interrupt the work that closed the layer. + async: true, + // Every terminal path resolves. Success alone strands the close behind a cancelled refresh. + onFinish: () => resolve(), + } + + if (!this.dispatchVisit(address, reload)) { + resolve() + } + }) + } + public clearHistory(): void { history.clear() } @@ -457,7 +621,7 @@ export class Router { return history.decrypt() } - public resolveComponent(component: string, page?: Page): Promise { + public resolveComponent(component: string, page?: Page): Promise { return currentPage.resolve(component, page) } @@ -468,7 +632,7 @@ export class Router { public replaceProp( name: string, value: unknown | ((oldValue: unknown, props: TProps) => unknown), - options?: Pick, + options?: Pick, ): void { this.replace({ preserveScroll: true, @@ -485,7 +649,7 @@ export class Router { public appendToProp( name: string, value: unknown | unknown[] | ((oldValue: unknown, props: TProps) => unknown | unknown[]), - options?: Pick, + options?: Pick, ): void { this.replaceProp( name, @@ -505,7 +669,7 @@ export class Router { public prependToProp( name: string, value: unknown | unknown[] | ((oldValue: unknown, props: TProps) => unknown | unknown[]), - options?: Pick, + options?: Pick, ): void { this.replaceProp( name, @@ -529,8 +693,10 @@ export class Router { public flash( keyOrData: string | ((flash: FlashData) => TFlash) | TFlash, value?: unknown, + { layerId = this.layerId }: { layerId?: string } = {}, ): void { - const current = currentPage.get().flash + const page = currentPage.get() + const current = tierOf(page, layerId).flash let flash: PageFlashData if (typeof keyOrData === 'function') { @@ -543,7 +709,7 @@ export class Router { return } - currentPage.setFlash(flash) + currentPage.setFlash(flash, layerAt(page, layerId)?.id) if (Object.keys(flash).length) { fireFlashEvent(flash) @@ -554,43 +720,76 @@ export class Router { params: ClientSideVisitOptions, { replace = false }: { replace?: boolean } = {}, ): void { - this.clientVisitQueue.add(() => this.performClientVisit(params, { replace })) + const visit = { ...params, layerId: params.layerId ?? this.layerId } + + this.clientVisitQueue.add(() => this.performClientVisit(visit, { replace })) } - protected performClientVisit( + protected async performClientVisit( params: ClientSideVisitOptions, { replace = false }: { replace?: boolean } = {}, ): Promise { + await layerClosing.unwindSettled() + const current = currentPage.get() + const targetedLayer = layerAt(current, params.layerId) + const tier = tierOf(current, params.layerId) + const onceProps = typeof params.props === 'function' ? Object.fromEntries( - Object.values(current.onceProps ?? {}).map((onceProp) => [ - onceProp.prop, - get(current.props, onceProp.prop), - ]), + Object.values(tier.onceProps ?? {}).map((onceProp) => [onceProp.prop, get(tier.props, onceProp.prop)]), ) : {} const props = typeof params.props === 'function' - ? params.props(current.props as TProps, onceProps as Partial) - : (params.props ?? current.props) + ? params.props(tier.props as TProps, onceProps as Partial) + : (params.props ?? tier.props) + + const flash = typeof params.flash === 'function' ? params.flash(tier.flash) : params.flash + + const { viewTransition, onError, onFinish, onFlash, onSuccess, layerId: _layerId, ...pageParams } = params + + let page: Page + let preservesBase: boolean + + if (targetedLayer) { + // Resolved against the layer's own page, so `preserveState: 'errors'` reads the layer's bag. + const keepsState = RequestParams.resolvePreserveOption( + params.preserveState ?? false, + layerPageOf(current, targetedLayer), + ) - const flash = typeof params.flash === 'function' ? params.flash(current.flash) : params.flash + page = withTier(current, targetedLayer.id, { + ...(pageParams.component !== undefined && { component: pageParams.component }), + ...(pageParams.url !== undefined && { url: pageParams.url }), + ...(pageParams.encryptHistory !== undefined && { encryptHistory: pageParams.encryptHistory }), + ...(keepsState ? {} : { renderKey: nextRenderKey() }), + flash: flash ?? {}, + props: props as Page['props'], + }) + preservesBase = true + } else { + preservesBase = replace && params.component === undefined - const { viewTransition, onError, onFinish, onFlash, onSuccess, ...pageParams } = params + const { layers, ...withoutStack } = current - const page = { - ...current, - ...pageParams, - flash: flash ?? {}, - props: props as Page['props'], + page = { + ...(preservesBase ? current : withoutStack), + ...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 tierPage = targetedLayer ? layerPageOf(current, targetedLayer) : page + + // A write that lands on a layer leaves the page beneath it standing, so its scroll stands too. + const preserveScroll = + !!targetedLayer || RequestParams.resolvePreserveOption(params.preserveScroll ?? false, tierPage) + const preserveState = RequestParams.resolvePreserveOption(params.preserveState ?? false, tierPage) const visitId = this.createVisitId() @@ -599,20 +798,24 @@ export class Router { replace, preserveScroll, preserveState, + preservesBase, viewTransition, visitId, }) .then(() => { fireClientVisitEvent(currentPage.get(), { replace, visitId }) - const currentFlash = currentPage.get().flash + const current = currentPage.get() + const announcedLayer = layerAt(current, params.layerId) + const currentFlash = announcedLayer ? (announcedLayer.flash ?? {}) : current.flash if (Object.keys(currentFlash).length > 0) { fireFlashEvent(currentFlash) onFlash?.(currentFlash) } - const errors = currentPage.get().props.errors || {} + const errors = ((announcedLayer ? announcedLayer.props.errors || {} : current.props.errors) || {}) as Errors & + ErrorBag if (Object.keys(errors).length === 0) { onSuccess?.(currentPage.get()) @@ -626,30 +829,43 @@ export class Router { .finally(() => onFinish?.(params)) } - protected performInstantSwap(visit: PendingVisit): Promise { + protected captureBase(): BaseSnapshot { + return { page: currentPage.get(), generation: currentPage.generation() } + } + + protected performInstantSwap(visit: PendingVisit): Promise { const current = currentPage.get() + const targetedLayer = layerAt(current, visit.layerId) + const tier = targetedLayer ?? current const sharedProps = Object.fromEntries( - (current.sharedProps ?? []).filter((key) => key in current.props).map((key) => [key, current.props[key]]), + (current.sharedProps ?? []).filter((key) => key in tier.props).map((key) => [key, tier.props[key]]), ) const resolvedPageProps = typeof visit.pageProps === 'function' - ? visit.pageProps(cloneDeep(current.props), cloneDeep(sharedProps)) + ? visit.pageProps(cloneDeep(tier.props), cloneDeep(sharedProps)) : visit.pageProps const intermediateProps = resolvedPageProps !== null ? { ...resolvedPageProps } : { ...sharedProps } + const props = { ...intermediateProps, errors: {} } + const url = visit.url.pathname + visit.url.search + visit.url.hash + + if (targetedLayer) { + return this.swapLayerInstantly(current, targetedLayer, visit, props, url).then(() => true) + } - const onceProps = this.preserveOncePropsOnInstantVisit(current, intermediateProps) + if (visit.layerOwner !== undefined) { + return this.openLayerInstantly(current, visit, props, url).then(() => true) + } + + const onceProps = this.preserveOncePropsOnInstantVisit(current, props) const intermediatePage: Page = { component: visit.component!, - url: visit.url.pathname + visit.url.search + visit.url.hash, + url, version: current.version, - props: { - ...intermediateProps, - errors: {}, - }, + props, flash: {}, rescuedProps: [], clearHistory: false, @@ -659,10 +875,67 @@ export class Router { rememberedState: {}, } - return currentPage.set(intermediatePage, { + return currentPage + .set(intermediatePage, { + replace: visit.replace, + preserveScroll: RequestParams.resolvePreserveOption(visit.preserveScroll, intermediatePage), + preserveState: false, + viewTransition: visit.viewTransition, + visitId: visit.id, + }) + .then(() => false) + } + + // The same fabrication aimed at one layer, so a link inside a layer renders instantly there + // instead of tearing the stack down to put the placeholder on the page. + protected swapLayerInstantly( + current: Page, + layer: LayerState, + visit: PendingVisit, + props: Page['props'], + url: string, + ): Promise { + const page = withTier(current, layer.id, { + component: visit.component!, + url, + renderKey: nextRenderKey(), + props, + flash: {}, + rescuedProps: [], + deferredProps: {}, + initialDeferredProps: undefined, + onceProps: this.preserveOncePropsOnInstantVisit(layer, props), + scrollProps: {}, + }) + + return currentPage.set(page, { replace: visit.replace, - preserveScroll: RequestParams.resolvePreserveOption(visit.preserveScroll, intermediatePage), - preserveState: false, + // The page beneath is staying, so its scroll is not the fabrication's to reset. + preserveScroll: true, + // The base beneath keeps its component and its id, so what lands next still composes onto it. + preserveState: true, + preservesBase: true, + viewTransition: visit.viewTransition, + visitId: visit.id, + }) + } + + // A visit that is opening a layer puts its placeholder up as a layer, leaving the page it opens + // on where it is. Keyed by the id the open minted, which the response then claims it under. + protected openLayerInstantly(current: Page, visit: PendingVisit, props: Page['props'], url: string): Promise { + const placeholder = { + component: visit.component!, + props, + url, + layer: { key: visit.layerId }, + version: current.version, + } as unknown as Page + + return currentPage.set(composeLayer(current, placeholder, visit.layerId!, { owner: visit.layerOwner! }), { + replace: visit.replace, + preserveScroll: true, + preserveState: true, + preservesBase: true, viewTransition: visit.viewTransition, visitId: visit.id, }) @@ -673,7 +946,7 @@ export class Router { * 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'] { + protected preserveOncePropsOnInstantVisit(current: Layer, props: PageProps): Page['onceProps'] { const onceProps: NonNullable = {} Object.entries(current.onceProps ?? {}).forEach(([key, onceProp]) => { @@ -798,8 +1071,15 @@ export class Router { } } - protected applyOptimisticUpdate(optimistic: OptimisticCallback, events: VisitCallbacks): void { - const currentProps = currentPage.get().props + protected applyOptimisticUpdate(optimistic: OptimisticCallback, events: VisitCallbacks, layerId?: string): void { + const layer = layerAt(currentPage.get(), layerId) + + if (layerId && !layer) { + return + } + + const tierPage = layer ? layerPageOf(currentPage.get(), layer) : currentPage.get() + const currentProps = tierPage.props const optimisticProps = optimistic(cloneDeep(currentProps)) if (!optimisticProps) { @@ -819,14 +1099,15 @@ export class Router { } const id = currentPage.nextOptimisticId() - const component = currentPage.get().component + const component = tierPage.component for (const key of changedKeys) { - currentPage.setBaseline(key, cloneDeep(currentProps[key])) + currentPage.setBaseline(key, cloneDeep(currentProps[key]), layerId) } - currentPage.registerOptimistic(id, optimistic) - currentPage.setPropsQuietly({ ...currentProps, ...optimisticProps }) + currentPage.registerOptimistic(id, layerId, optimistic) + + currentPage.setPropsQuietly({ ...currentProps, ...optimisticProps }, layerId) let shouldRestore = true @@ -840,11 +1121,13 @@ export class Router { events.onFinish = (visit) => { currentPage.unregisterOptimistic(id) - if (shouldRestore && currentPage.get().component === component) { - const replayedProps = currentPage.replayOptimistics() + const tier = layerId ? layerAt(currentPage.get(), layerId) : currentPage.get() + + if (shouldRestore && tier?.component === component) { + const replayedProps = currentPage.replayOptimistics(layerId) if (Object.keys(replayedProps).length > 0) { - currentPage.setPropsQuietly({ ...currentPage.get().props, ...replayedProps }) + currentPage.setPropsQuietly({ ...tier.props, ...replayedProps } as Page['props'], layerId) } } @@ -856,11 +1139,56 @@ export class Router { } } - protected loadDeferredProps(deferred: Page['deferredProps']): void { + protected loadDeferredProps(deferred: Page['deferredProps'], layerId?: string): void { if (deferred) { Object.values(deferred).forEach((props) => { - this.doReload({ only: props, deferredProps: true, preserveErrors: true }) + this.doReload({ + only: props, + deferredProps: true, + preserveErrors: true, + ...(layerId && { layerId }), + }) }) } } } + +// A layer from the inside: the router bound to it, plus the layer's own handle. `layer.post()` is +// `router.post()` aimed at the layer, and `layer.layer()` opens a child this layer owns. +export interface LayerApi extends Pick< + Router, + | 'visit' + | 'get' + | 'post' + | 'put' + | 'patch' + | 'delete' + | 'reload' + | 'poll' + | 'layer' + | 'push' + | 'replace' + | 'replaceProp' + | 'appendToProp' + | 'prependToProp' + | 'flash' +> { + id: string | undefined + close(): Promise + onClose(callback: () => void): () => void + emit(name: string, payload?: unknown): void + on(name: string, callback: (payload?: unknown, childId?: string) => void): () => void + once(name: string, callback: (payload?: unknown, childId?: string) => void): () => void +} + +export const createLayerApi = (id: string | undefined): LayerApi => + Object.assign(new Router(id), { + id, + close: () => (id === undefined ? Promise.resolve() : router.layerHandle(id).close()), + onClose: (callback: () => void) => router.layerHandle(id).onClose(callback), + emit: (name: string, payload?: unknown) => router.layerHandle(id).emit(name, payload), + on: (name: string, callback: (payload?: unknown, childId?: string) => void) => + router.layerHandle(id).on(name, callback), + once: (name: string, callback: (payload?: unknown, childId?: string) => void) => + router.layerHandle(id).once(name, callback), + }) diff --git a/packages/core/src/scroll.ts b/packages/core/src/scroll.ts index f0551dbdd..1ac0f7009 100644 --- a/packages/core/src/scroll.ts +++ b/packages/core/src/scroll.ts @@ -7,7 +7,7 @@ const isFirefox = !isServer && /Firefox/i.test(window.navigator.userAgent) export class Scroll { public static save(): void { - history.saveScrollPositions(this.getScrollRegions()) + history.saveScrollPositions(this.getScrollRegions(), this.getScrollRegionLayers()) } public static getScrollRegions(): ScrollRegion[] { @@ -17,6 +17,16 @@ export class Scroll { })) } + public static getScrollRegionLayers(): (string | null)[] | undefined { + const tiers = Array.from(this.regions()).map((region) => this.tierOf(region)) + + return tiers.some((tier) => tier !== null) ? tiers : undefined + } + + protected static tierOf(region: Element): string | null { + return region.closest('[data-layer-id]')?.getAttribute('data-layer-id') ?? null + } + protected static regions(): NodeListOf { return document.querySelectorAll('[scroll-region]') } @@ -71,17 +81,25 @@ export class Scroll { window.requestAnimationFrame(() => { this.restoreDocument() - this.restoreScrollRegions(scrollRegions) + this.restoreScrollRegions(scrollRegions, history.getScrollRegionLayers()) }) } - public static restoreScrollRegions(scrollRegions: ScrollRegion[]): void { + public static restoreScrollRegions(scrollRegions: ScrollRegion[], scrollRegionLayers?: (string | null)[]): void { if (isServer) { return } + // A region takes the next saved position of its own tier. An entry written before layers + // existed carries no tiers, and lines up by index as it always did. + const savedByTier = new Map() + + scrollRegionLayers?.forEach((tier, index) => { + savedByTier.set(tier, [...(savedByTier.get(tier) ?? []), scrollRegions[index]]) + }) + this.regions().forEach((region: Element, index: number) => { - const scrollPosition = scrollRegions[index] + const scrollPosition = scrollRegionLayers ? savedByTier.get(this.tierOf(region))?.shift() : scrollRegions[index] if (!scrollPosition) { return diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 4a0954625..451323814 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -223,8 +223,58 @@ export type ScrollProp = { reset: boolean } -export interface Page { +/** What every tier of the screen holds. A page is a layer; a layer is not a page. */ +export interface Layer { component: string + props: PageProps + /** Null for a layer with no url of its own; the address then falls through to the layer beneath it. */ + url: string | null + encryptHistory?: boolean + deferredProps?: Record> + initialDeferredProps?: Record> + rescuedProps: string[] + flash: FlashData + onceProps?: Record + scrollProps?: Record +} + +export interface LayerState extends Layer { + id: string + key: string + /** Changes when a write lands on the layer without preserving state, so its component remounts. */ + renderKey: number + base: string | null + encryptHistory: boolean + /** Opened over a base the user was never on, which is what says where closing it returns to. */ + standalone: boolean + /** The entries pushed while this layer owned the address, which closing it steps back over. */ + entries: number + owner: string | null + deferredProps: Record> + /** @internal */ + local?: boolean + /** @internal */ + preservesUrl?: boolean +} + +export interface LayerShellProps { + open: boolean + index: number + isTop: boolean + type: 'routed' | 'local' + close: () => void + done: () => void + /** The dialog's accessible name. Nothing here sets it: only the app's own shell knows the name. */ + label?: string +} + +export type ResolvedLayer = Omit & { + component: ComponentType + page: Page + isClosing: boolean +} + +export interface Page extends Layer { props: PageProps & SharedProps & { errors: Errors & ErrorBag @@ -234,24 +284,18 @@ export interface Page { version: string | null clearHistory?: boolean preserveFragment?: boolean - encryptHistory?: boolean - deferredProps?: Record> - initialDeferredProps?: Record> - rescuedProps: string[] + interstitial?: boolean mergeProps?: string[] prependProps?: string[] deepMergeProps?: string[] matchPropsOn?: string[] sharedProps?: string[] - scrollProps?: Record - flash: FlashData - onceProps?: Record< - string, - { - prop: keyof PageProps - expiresAt?: number | null - } - > + layers?: LayerState[] + layer?: { + key?: string + base?: string + } + close?: boolean /** @internal */ rememberedState: Record @@ -259,6 +303,22 @@ export interface Page { optimisticUpdatedAt?: Record } +/** + * A layer opened from the client rather than fetched. It has no url of its own, but is still a + * history step: back closes it and forward brings it back. + */ +export interface LocalLayer { + component: string + props?: PageProps +} + +// An instant visit fabricates a page before its request goes out, so the base a visit was +// dispatched from is captured up front rather than read back at send time. +export interface BaseSnapshot { + page: Page + generation: number +} + export type ScrollRegion = { top: number left: number @@ -275,6 +335,8 @@ export interface ClientSideVisitOptions { preserveState?: VisitOptions['preserveState'] errorBag?: string | null viewTransition?: VisitOptions['viewTransition'] + /** @internal */ + layerId?: string onError?: (errors: Errors) => void onFinish?: (visit: ClientSideVisitOptions) => void onFlash?: (flash: FlashData) => void @@ -283,14 +345,30 @@ export interface ClientSideVisitOptions { export type PageResolver = (name: string, page?: Page) => Component +/** Resolves the placeholder rendered while a cold-opened layer's base is still being fetched. */ +export type LoadingResolver = (url: string, page: Page) => Component | Promise | undefined + +export type LoadingOption = + | ComponentType + | (( + url: string, + page: Page, + ) => + | ComponentType + | { default: ComponentType } + | Promise + | undefined) + export type PageHandler = ({ component, page, + layers, preserveState, initialRender, }: { - component: ComponentType + component?: ComponentType page: Page + layers?: ResolvedLayer[] preserveState: boolean initialRender: boolean }) => Promise @@ -341,6 +419,10 @@ export type Visit = { | ((currentProps: PageProps, sharedProps: Partial) => Record) | null cached: boolean + /** @internal */ + layerId?: string + /** @internal */ + layerOwner?: string } export type GlobalEventsMap = { @@ -388,6 +470,7 @@ export type GlobalEventsMap = { parameters: [Page, { cached?: boolean; visitId?: string }?] details: { page: Page + url: string cached?: boolean visitId?: string } @@ -406,6 +489,7 @@ export type GlobalEventsMap = { parameters: [Page, { visitId?: string }?] details: { page: Page + url: string visitId?: string } result: void @@ -498,7 +582,7 @@ export type GlobalEventCallback, T extend ...params: GlobalEventParameters ) => GlobalEventResult -export type InternalEvent = 'missingHistoryItem' | 'loadDeferredProps' | 'historyQuotaExceeded' +export type InternalEvent = 'missingHistoryItem' | 'loadDeferredProps' | 'historyQuotaExceeded' | 'historyEntryDropped' export type VisitCallbacks = { onCancelToken: CancelTokenCallback @@ -535,6 +619,7 @@ export type VisitHelperOptions = Omit export type RouterInitParams = { initialPage: Page resolveComponent: PageResolver + resolveLoading?: LoadingResolver swapComponent: PageHandler onFlash?: (flash: Page['flash']) => void } @@ -551,14 +636,22 @@ export type PendingVisitOptions = { export type PendingVisit = Visit & PendingVisitOptions export type ActiveVisit = PendingVisit & - Required, 'optimistic'>> + Required, 'optimistic' | 'layerId' | 'layerOwner'>> & { + /** @internal */ + layerId?: string + /** @internal */ + layerOwner?: string + } export type InternalActiveVisit = ActiveVisit & { onPrefetchResponse?: (response: Response) => void onPrefetchError?: (error: Error) => void deferredProps?: boolean poll?: boolean + reload?: boolean cached?: boolean + walk?: boolean + fabricatedLayer?: boolean } export type VisitId = string @@ -654,7 +747,7 @@ export type HeadManagerOnUpdateCallback = (elements: string[]) => void export type HeadManager = { forceUpdate: () => void updateServerHead: (elements?: string[]) => void - createProvider: () => { + createProvider: (layerId?: string) => { reconnect: () => void update: HeadManagerOnUpdateCallback disconnect: () => void @@ -873,6 +966,8 @@ export type FormComponentSlotProps> = export type FormComponentRef> = FormComponentSlotProps export interface UseInfiniteScrollOptions { + /** @internal */ + layerId?: string // Core data getPropName: () => string inReverseMode: () => boolean diff --git a/packages/core/src/url.ts b/packages/core/src/url.ts index 5a80c6c04..7ee8fdb77 100644 --- a/packages/core/src/url.ts +++ b/packages/core/src/url.ts @@ -11,8 +11,11 @@ import type { VisitOptions, } from './types' +// A relative href needs an origin to resolve against, and a server has no location to read one from. +const ssrOrigin = 'http://ssr.inertia' + export function hrefToUrl(href: string | URL): URL { - return new URL(href.toString(), typeof window === 'undefined' ? undefined : window.location.toString()) + return new URL(href.toString(), typeof window === 'undefined' ? ssrOrigin : window.location.toString()) } export const transformUrlAndData = ( diff --git a/packages/core/tests/head.test.ts b/packages/core/tests/head.test.ts index acd9703c0..ebcb40149 100644 --- a/packages/core/tests/head.test.ts +++ b/packages/core/tests/head.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import createHeadManager, { resolveServerHead } from '../src/head' -import { Page } from '../src/types' +import { LayerState, Page } from '../src/types' const page = (props: Page['props'] = {}): Page => ({ component: 'Users/Index', @@ -15,6 +15,33 @@ const page = (props: Page['props'] = {}): Page => ({ rememberedState: {}, }) +const layer = (overrides: Partial = {}): LayerState => ({ + id: 'layer-1', + key: 'Users/Edit', + component: 'Users/Edit', + props: {}, + url: '/users/5/edit', + base: '/users', + encryptHistory: false, + standalone: true, + entries: 0, + owner: null, + ...overrides, +}) + +const coldComposite = (headProps: Page['props'] = {}): Page => + ({ + ...page({ errors: {} }), + component: '', + layers: [layer({ component: 'SSR/Layer', props: { ...headProps } })], + }) as Page + +const warmComposite = (headProps: Page['props'], baseHeadProps: Page['props']): Page => + ({ + ...page({ errors: {}, ...baseHeadProps }), + layers: [layer({ component: 'Users/Edit', standalone: false, props: { ...headProps } })], + }) as Page + describe('createHeadManager', () => { describe('server head', () => { it('normalizes server head elements into Inertia managed elements', () => { @@ -35,6 +62,29 @@ describe('createHeadManager', () => { ]) }) + it('resolves a cold layer server head from the top layer when the base is blank', () => { + expect( + resolveServerHead(coldComposite({ head: ['Layer'] }), (p) => p.props.head as string[]), + ).toEqual(['Layer']) + }) + + it('resolves a cold layer server head from a named prop on the top layer', () => { + expect( + resolveServerHead(coldComposite({ metaTags: [''] }), 'metaTags'), + ).toEqual(['']) + }) + + it('resolves a warm layer server head from the top layer, not the base beneath it', () => { + expect( + resolveServerHead(warmComposite({ head: ['Layer'] }, { head: ['Base'] }), true), + ).toEqual(['Layer']) + }) + + it('returns no head for a cold layer that carries none', () => { + expect(resolveServerHead(coldComposite(), true)).toEqual([]) + expect(resolveServerHead(coldComposite(), (p) => p.props.head as string[])).toEqual([]) + }) + it('updates server head elements using a reserved provider', () => { const collected: string[][] = [] @@ -111,4 +161,107 @@ describe('createHeadManager', () => { expect(head).not.toContain(' - + +{#if renderProps}{/if}{#each layers as layer, index (layer.id)} + + +
+ +
+
+
+{/each} diff --git a/packages/svelte/src/components/Deferred.svelte b/packages/svelte/src/components/Deferred.svelte index 3b446a627..3fd2d91d4 100644 --- a/packages/svelte/src/components/Deferred.svelte +++ b/packages/svelte/src/components/Deferred.svelte @@ -1,7 +1,8 @@ + + + {@render children()} + diff --git a/packages/svelte/src/components/LayerPageContext.svelte b/packages/svelte/src/components/LayerPageContext.svelte new file mode 100644 index 000000000..4ebfd3134 --- /dev/null +++ b/packages/svelte/src/components/LayerPageContext.svelte @@ -0,0 +1,29 @@ + + +{@render children?.()} diff --git a/packages/svelte/src/components/Link.svelte b/packages/svelte/src/components/Link.svelte index 1d3620161..2754c7990 100644 --- a/packages/svelte/src/components/Link.svelte +++ b/packages/svelte/src/components/Link.svelte @@ -2,6 +2,9 @@ import { isUrlMethodPair, resolveUrlMethodPairComponent } from '@inertiajs/core' import type { LinkComponentBaseProps } from '@inertiajs/core' import { inertia } from '../index' + import { layerId } from '../page.svelte' + + const currentLayerId = layerId() interface Props { href?: LinkComponentBaseProps['href'] @@ -95,6 +98,7 @@ import { router, type ReloadOptions } from '@inertiajs/core' import { get } from 'es-toolkit/compat' - import { usePage } from '../page.svelte' + import { layerId, usePage } from '../page.svelte' + + const currentLayerId = layerId() interface Props { data?: string | string[] @@ -42,6 +44,7 @@ const reloadParams = getReloadParams() router.reload({ + layerId: currentLayerId, ...reloadParams, onStart: (event) => { fetching = true diff --git a/packages/svelte/src/createInertiaApp.ts b/packages/svelte/src/createInertiaApp.ts index dc1069720..e27eeaf9f 100644 --- a/packages/svelte/src/createInertiaApp.ts +++ b/packages/svelte/src/createInertiaApp.ts @@ -4,12 +4,15 @@ import { exposeInterceptors, getInitialPageFromDOM, http as httpModule, + normalizeLoading, + resolveInitialPage, resolveServerHead, router, setupProgress, type CreateInertiaAppOptions, type CreateInertiaAppOptionsForCSR, type InertiaAppSSRResponse, + type LoadingOption, type Page, type PageProps, type SharedPageProps, @@ -17,7 +20,7 @@ import { import { hydrate, mount } from 'svelte' import App, { type InertiaAppProps } from './components/App.svelte' import { config } from './index' -import type { ComponentResolver, ResolvedComponent, SvelteInertiaAppConfig } from './types' +import type { ComponentResolver, LayerComponent, ResolvedComponent, SvelteInertiaAppConfig } from './types' type SvelteRenderResult = { body: string; head: string } @@ -39,6 +42,8 @@ type InertiaAppOptionsForCSR = CreateInertiaAppOp SvelteRenderResult | void, SvelteInertiaAppConfig > & { + layer?: LayerComponent + loading?: LoadingOption withApp?: never } @@ -52,6 +57,8 @@ type InertiaAppOptionsAuto = Omit< 'setup' > & { page?: Page + layer?: LayerComponent + loading?: LoadingOption } & ( | { setup?: undefined; withApp?: SvelteWithApp } | { setup: (options: SetupOptions) => SvelteRenderResult | void; withApp?: never } @@ -84,6 +91,8 @@ export default async function createInertiaApp Promise.resolve(resolve!(name, page)) + // The renderer reads `component.default`, so a bare component is wrapped the way a module arrives. + const resolveLoading = normalizeLoading(loading, { + normalize: (value) => + (value && typeof value === 'object' && 'default' in value ? value : { default: value }) as ResolvedComponent, + }) + // SSR render function factory - when on server without page, return a render function // This is used by the Vite plugin's SSR transform if (isServer && !page) { return async (page: Page, render: SvelteServerRender) => { - const initialComponent = (await resolveComponent(page.component, page)) as ResolvedComponent + const { + page: initialPage, + component: initialComponent, + layers: initialLayers, + } = await resolveInitialPage(page, resolveComponent, resolveLoading) const props: InertiaAppProps = { - initialPage: page, + initialPage, initialComponent, + initialLayers, resolveComponent, + resolveLoading, defaultLayout: layout, + layer, } let svelteApp: SvelteRenderResult @@ -144,12 +166,18 @@ export default async function createInertiaApp>(id)! + const initialResponse = page || getInitialPageFromDOM>(id)! + + const [{ page: initialPage, component: initialComponent, layers: initialLayers }] = await Promise.all([ + resolveInitialPage(initialResponse, resolveComponent, resolveLoading), + router.decryptHistory().catch(() => {}), + ]) + const serverHeadManager = !isServer && serverHead ? createHeadManager( @@ -160,12 +188,15 @@ export default async function createInertiaApp, - router.decryptHistory().catch(() => {}), - ]) - - const props: InertiaAppProps = { initialPage, initialComponent, resolveComponent, defaultLayout: layout } + const props: InertiaAppProps = { + initialPage, + initialComponent, + initialLayers, + resolveComponent, + resolveLoading, + defaultLayout: layout, + layer, + } // SSR with page provided (legacy pattern used by ssr.ts) if (isServer) { @@ -176,7 +207,7 @@ export default async function createInertiaApp>, }) +export const layerState = $state>({}) + store.subscribe(() => { const snapshot = store.get() storeState.shared = snapshot.shared storeState.named = snapshot.named -}) -export function setLayoutProps(props: Partial): void -export function setLayoutProps(name: K, props: Partial): void -export function setLayoutProps(props: Partial>): void -export function setLayoutProps(name: string, props: Partial>): void -export function setLayoutProps(nameOrProps: string | Record, props?: Record): void { - if (typeof nameOrProps === 'string') { - store.setFor(nameOrProps, props!) - } else { - store.set(nameOrProps) + const known = new Set(store.layerIds()) + for (const id of known) { + layerState[id] = store.getForLayer(id) } -} + for (const id of Object.keys(layerState)) { + if (!known.has(id)) { + delete layerState[id] + } + } +}) + +export const setLayoutProps = store.set export function resetLayoutProps(): void { store.reset() @@ -31,3 +33,7 @@ export function resetLayoutProps(): void { storeState.shared = snapshot.shared storeState.named = snapshot.named } + +export function retainLayerLayoutProps(ids: string[]): void { + store.retainLayers(ids) +} diff --git a/packages/svelte/src/link.ts b/packages/svelte/src/link.ts index b84fcce42..fc9f0d674 100644 --- a/packages/svelte/src/link.ts +++ b/packages/svelte/src/link.ts @@ -171,6 +171,7 @@ function link( } baseParams = { + layerId: params.layerId, data, method, replace: params.replace || false, diff --git a/packages/svelte/src/page.svelte.ts b/packages/svelte/src/page.svelte.ts index abd06c3dd..bfe87c22b 100644 --- a/packages/svelte/src/page.svelte.ts +++ b/packages/svelte/src/page.svelte.ts @@ -1,4 +1,5 @@ -import { type Page, type PageProps, type SharedPageProps } from '@inertiajs/core' +import { createLayerApi, type LayerApi, type Page, type PageProps, type SharedPageProps } from '@inertiajs/core' +import { getContext } from 'svelte' type SveltePage = Omit, 'props'> & { props: Page['props'] & { @@ -13,11 +14,40 @@ const page = $state({ version: null, } as SveltePage) +export const layerPageKey = Symbol('inertiaLayerPage') +export const layerIdKey = Symbol('inertiaLayerId') + +export function layerId(): string | undefined { + try { + return getContext(layerIdKey) + } catch { + return undefined + } +} + +export function useLayer(): LayerApi { + return createLayerApi(layerId()) +} + export function setPage(newPage: SveltePage) { + for (const key of Object.keys(page)) { + if (!(key in newPage)) { + delete (page as Record)[key] + } + } + Object.assign(page, newPage) } export function usePage(): SveltePage { + try { + const layerPage = getContext(layerPageKey) + + if (layerPage) { + return layerPage as SveltePage + } + } catch {} + return page as SveltePage } diff --git a/packages/svelte/src/types.ts b/packages/svelte/src/types.ts index c8563b55a..f3aa29dfe 100644 --- a/packages/svelte/src/types.ts +++ b/packages/svelte/src/types.ts @@ -1,5 +1,5 @@ -import { type LayoutCallbackReturn, type Page, type SharedPageProps } from '@inertiajs/core' -import type { Component } from 'svelte' +import { type LayerShellProps, type LayoutCallbackReturn, type Page, type SharedPageProps } from '@inertiajs/core' +import type { Component, Snippet } from 'svelte' import type { RenderFunction, RenderProps } from './components/Render.svelte' export type ComponentResolver = ( @@ -7,6 +7,8 @@ export type ComponentResolver = ( page?: Page, ) => ResolvedComponent | Promise +export type LayerComponent = Component + export type LayoutResolver = (h: RenderFunction, page: RenderProps) => RenderProps export type LayoutCallback = (props: SharedPageProps) => LayoutCallbackReturn diff --git a/packages/svelte/src/useForm.svelte.ts b/packages/svelte/src/useForm.svelte.ts index 655d79e22..355e0fc83 100644 --- a/packages/svelte/src/useForm.svelte.ts +++ b/packages/svelte/src/useForm.svelte.ts @@ -162,6 +162,7 @@ export default function useForm>( setRememberExcludeKeys, resetBeforeSubmit, finishProcessing, + layerId, } = useFormState({ data, rememberKey, @@ -179,6 +180,7 @@ export default function useForm>( const transformedData = getTransform()(form.data()) as RequestPayload const _options: Omit = { + layerId, ...options, onCancelToken: (token: CancelToken) => { cancelToken = token diff --git a/packages/svelte/src/useFormState.svelte.ts b/packages/svelte/src/useFormState.svelte.ts index 200e8edf7..6e362b9dc 100644 --- a/packages/svelte/src/useFormState.svelte.ts +++ b/packages/svelte/src/useFormState.svelte.ts @@ -15,6 +15,7 @@ import { get, has, set } from 'es-toolkit/compat' import type { NamedInputEvent, ValidationConfig, Validator } from 'laravel-precognition' import { createValidator, resolveName, toSimpleValidationErrors } from 'laravel-precognition' import { config } from '.' +import { layerId as currentLayerId } from './page.svelte' type TransformCallback = (data: TForm) => object @@ -99,18 +100,20 @@ export interface UseFormStateReturn { resetBeforeSubmit: () => void finishProcessing: () => void withAllErrors: { enabled: () => boolean; enable: () => void } + layerId: string | undefined } export default function useFormState( options: UseFormStateOptions, ): UseFormStateReturn { const { data: dataOption, rememberKey, precognitionEndpoint: initialPrecognitionEndpoint } = options + const layerId = currentLayerId() const isDataFunction = typeof dataOption === 'function' const resolveData = () => (isDataFunction ? (dataOption as () => TForm)() : dataOption) const restored = rememberKey - ? (router.restore(rememberKey) as { data: TForm; errors: Record, ErrorValue> } | null) + ? (router.restore(rememberKey, layerId) as { data: TForm; errors: Record, ErrorValue> } | null) : null const initialData = restored?.data ?? cloneDeep(resolveData()) @@ -390,11 +393,11 @@ export default function useFormState( return } - const storedData = router.restore(rememberKey) + const storedData = router.restore(rememberKey, layerId) const newData = (form as unknown as InternalRememberState).__remember() if (!isEqual(storedData, newData)) { - router.remember(newData, rememberKey) + router.remember(newData, rememberKey, layerId) } }) @@ -404,6 +407,7 @@ export default function useFormState( return { form: form as FormState & InternalRememberState, + layerId, setDefaults: (newDefaults: TForm) => { defaults = newDefaults }, diff --git a/packages/svelte/src/usePoll.svelte.ts b/packages/svelte/src/usePoll.svelte.ts index a836920c2..d94c37e12 100644 --- a/packages/svelte/src/usePoll.svelte.ts +++ b/packages/svelte/src/usePoll.svelte.ts @@ -1,5 +1,6 @@ -import { router, type PollOptions, type ReloadOptions } from '@inertiajs/core' +import { type PollOptions, type ReloadOptions } from '@inertiajs/core' import { onDestroy, onMount } from 'svelte' +import { useLayer } from './page.svelte' export default function usePoll( interval: number, @@ -11,7 +12,7 @@ export default function usePoll( ) { const autoStart = options.autoStart ?? true - const { stop, start, destroy } = router.poll(interval, requestOptions, { + const { stop, start, destroy } = useLayer().poll(interval, requestOptions, { ...options, autoStart: false, }) diff --git a/packages/svelte/src/useRemember.svelte.ts b/packages/svelte/src/useRemember.svelte.ts index 705c57945..cd7b42003 100644 --- a/packages/svelte/src/useRemember.svelte.ts +++ b/packages/svelte/src/useRemember.svelte.ts @@ -1,12 +1,14 @@ import { router } from '@inertiajs/core' import { cloneDeep } from 'es-toolkit' +import { layerId as currentLayerId } from './page.svelte' export default function useRemember(initialState: State, key?: string): State { - const restored = router.restore(key) as State | undefined + const layerId = currentLayerId() + const restored = router.restore(key, layerId) as State | undefined const state = $state(restored !== undefined ? cloneDeep(restored) : initialState) $effect(() => { - router.remember(cloneDeep($state.snapshot(state)), key) + router.remember(cloneDeep($state.snapshot(state)), key, layerId) }) return state diff --git a/packages/svelte/test-app/Layouts/AdminLayout.svelte b/packages/svelte/test-app/Layouts/AdminLayout.svelte new file mode 100644 index 000000000..72708a0f0 --- /dev/null +++ b/packages/svelte/test-app/Layouts/AdminLayout.svelte @@ -0,0 +1,12 @@ + + +
+ Admin Layout + {@render children?.()} +
diff --git a/packages/svelte/test-app/Layouts/DefaultLayout.svelte b/packages/svelte/test-app/Layouts/DefaultLayout.svelte index d7031e9ac..92922a0d5 100644 --- a/packages/svelte/test-app/Layouts/DefaultLayout.svelte +++ b/packages/svelte/test-app/Layouts/DefaultLayout.svelte @@ -1,12 +1,18 @@
Default Layout +
{baseChrome ?? ''}
+
{layerChrome ?? ''}
+
{name ?? ''}
{@render children?.()}
diff --git a/packages/svelte/test-app/Layouts/Layer.svelte b/packages/svelte/test-app/Layouts/Layer.svelte new file mode 100644 index 000000000..e63a77956 --- /dev/null +++ b/packages/svelte/test-app/Layouts/Layer.svelte @@ -0,0 +1,23 @@ + + + + {@render children()} + diff --git a/packages/svelte/test-app/Pages/History/NonSerializable.svelte b/packages/svelte/test-app/Pages/History/NonSerializable.svelte new file mode 100644 index 000000000..c58a9493a --- /dev/null +++ b/packages/svelte/test-app/Pages/History/NonSerializable.svelte @@ -0,0 +1,13 @@ + + + diff --git a/packages/svelte/test-app/Pages/History/NonSerializableTarget.svelte b/packages/svelte/test-app/Pages/History/NonSerializableTarget.svelte new file mode 100644 index 000000000..a5fda2d4e --- /dev/null +++ b/packages/svelte/test-app/Pages/History/NonSerializableTarget.svelte @@ -0,0 +1,22 @@ + + + + +
{message}
diff --git a/packages/svelte/test-app/Pages/Layers/Base.svelte b/packages/svelte/test-app/Pages/Layers/Base.svelte new file mode 100644 index 000000000..da36017e9 --- /dev/null +++ b/packages/svelte/test-app/Pages/Layers/Base.svelte @@ -0,0 +1,109 @@ + + +
Base page
+ +
+ +{#if page.props.errors?.name} +

{page.props.errors.name}

+{/if} + +
{page.layers?.length ?? 0}
+
{visitEvents.join(',')}
+
{(page.flash as { message?: string })?.message ?? ''}
+
{likes}
+
{childEvents}
+
{cancelledEvent}
+ + + + +Open panel +Open guarded panel +Open second panel +Open third panel +Open slow panel +Open delayed panel +Open settings +Open step one +Open step two +Open counted panel +Open deferred layer + + + + + + + + + + + + diff --git a/packages/svelte/test-app/Pages/Layers/ChainInner.svelte b/packages/svelte/test-app/Pages/Layers/ChainInner.svelte new file mode 100644 index 000000000..a1b7989e5 --- /dev/null +++ b/packages/svelte/test-app/Pages/Layers/ChainInner.svelte @@ -0,0 +1 @@ +
Chain inner
diff --git a/packages/svelte/test-app/Pages/Layers/ChainOuter.svelte b/packages/svelte/test-app/Pages/Layers/ChainOuter.svelte new file mode 100644 index 000000000..669780f5c --- /dev/null +++ b/packages/svelte/test-app/Pages/Layers/ChainOuter.svelte @@ -0,0 +1 @@ +
Chain outer
diff --git a/packages/svelte/test-app/Pages/Layers/Child.svelte b/packages/svelte/test-app/Pages/Layers/Child.svelte new file mode 100644 index 000000000..db842e111 --- /dev/null +++ b/packages/svelte/test-app/Pages/Layers/Child.svelte @@ -0,0 +1,9 @@ + + +
Child layer
+ + diff --git a/packages/svelte/test-app/Pages/Layers/Deferred.svelte b/packages/svelte/test-app/Pages/Layers/Deferred.svelte new file mode 100644 index 000000000..aa55357cd --- /dev/null +++ b/packages/svelte/test-app/Pages/Layers/Deferred.svelte @@ -0,0 +1,14 @@ + + +
Deferred layer: {name}
+ + + {#snippet fallback()} +
Loading stats...
+ {/snippet} +
{stats?.join(',')}
+
diff --git a/packages/svelte/test-app/Pages/Layers/Guarded.svelte b/packages/svelte/test-app/Pages/Layers/Guarded.svelte new file mode 100644 index 000000000..53b8aab2e --- /dev/null +++ b/packages/svelte/test-app/Pages/Layers/Guarded.svelte @@ -0,0 +1 @@ +
Guarded layer
diff --git a/packages/svelte/test-app/Pages/Layers/Infinite.svelte b/packages/svelte/test-app/Pages/Layers/Infinite.svelte new file mode 100644 index 000000000..07bacb393 --- /dev/null +++ b/packages/svelte/test-app/Pages/Layers/Infinite.svelte @@ -0,0 +1,28 @@ + + +
Infinite layer
+ + + {#snippet previous({ hasMore, loading, fetch })} + + Has more previous: {hasMore} + {/snippet} + + {#each users.data as user (user.id)} + + {/each} + + {#snippet next({ hasMore, loading, fetch })} + + Has more next: {hasMore} + {/snippet} + diff --git a/packages/svelte/test-app/Pages/Layers/LoadingBase.svelte b/packages/svelte/test-app/Pages/Layers/LoadingBase.svelte new file mode 100644 index 000000000..2ccb99d0c --- /dev/null +++ b/packages/svelte/test-app/Pages/Layers/LoadingBase.svelte @@ -0,0 +1 @@ +
Loading the page beneath
diff --git a/packages/svelte/test-app/Pages/Layers/Local.svelte b/packages/svelte/test-app/Pages/Layers/Local.svelte new file mode 100644 index 000000000..cf7400e3a --- /dev/null +++ b/packages/svelte/test-app/Pages/Layers/Local.svelte @@ -0,0 +1,7 @@ + + +
Local layer
+
{note}
+
{typeof errors}
diff --git a/packages/svelte/test-app/Pages/Layers/Login.svelte b/packages/svelte/test-app/Pages/Layers/Login.svelte new file mode 100644 index 000000000..04ed8a8e3 --- /dev/null +++ b/packages/svelte/test-app/Pages/Layers/Login.svelte @@ -0,0 +1,11 @@ + + +
Sign in to continue
+ + diff --git a/packages/svelte/test-app/Pages/Layers/ModuleScope.svelte b/packages/svelte/test-app/Pages/Layers/ModuleScope.svelte new file mode 100644 index 000000000..b8ac496b4 --- /dev/null +++ b/packages/svelte/test-app/Pages/Layers/ModuleScope.svelte @@ -0,0 +1,24 @@ + + + + +
Module scope page
+
{page.component}|{page.url}
diff --git a/packages/svelte/test-app/Pages/Layers/Panel.svelte b/packages/svelte/test-app/Pages/Layers/Panel.svelte new file mode 100644 index 000000000..6aae9270c --- /dev/null +++ b/packages/svelte/test-app/Pages/Layers/Panel.svelte @@ -0,0 +1,115 @@ + + +
Panel layer: {name}
+ +
+ {(page.props as { name?: string }).name}|{page.url}|{page.component} +
+ +
{count}
+ +
{childEvents}
+ +{#if errors?.note} +

{errors.note}

+{/if} + + + + +{#if form.errors.note} +

{form.errors.note}

+{/if} + + + + + + + + + + + + + + + + diff --git a/packages/svelte/test-app/Pages/Layers/Prompt.svelte b/packages/svelte/test-app/Pages/Layers/Prompt.svelte new file mode 100644 index 000000000..335fec0e3 --- /dev/null +++ b/packages/svelte/test-app/Pages/Layers/Prompt.svelte @@ -0,0 +1,11 @@ + + +
Sudo prompt
+ + diff --git a/packages/svelte/test-app/Pages/Layers/ScrollBase.svelte b/packages/svelte/test-app/Pages/Layers/ScrollBase.svelte new file mode 100644 index 000000000..1790d25b6 --- /dev/null +++ b/packages/svelte/test-app/Pages/Layers/ScrollBase.svelte @@ -0,0 +1,4 @@ +
Scroll base page
+
+
Base scrollable content
+
diff --git a/packages/svelte/test-app/Pages/Layers/ScrollLayer.svelte b/packages/svelte/test-app/Pages/Layers/ScrollLayer.svelte new file mode 100644 index 000000000..202be5a0f --- /dev/null +++ b/packages/svelte/test-app/Pages/Layers/ScrollLayer.svelte @@ -0,0 +1,4 @@ +
Scroll layer
+
+
Layer scrollable content
+
diff --git a/packages/svelte/test-app/Pages/Layers/Settings.svelte b/packages/svelte/test-app/Pages/Layers/Settings.svelte new file mode 100644 index 000000000..f0cd51d83 --- /dev/null +++ b/packages/svelte/test-app/Pages/Layers/Settings.svelte @@ -0,0 +1,10 @@ + + +
Account settings
+Open settings again diff --git a/packages/svelte/test-app/Pages/Layers/Slow.svelte b/packages/svelte/test-app/Pages/Layers/Slow.svelte new file mode 100644 index 000000000..dbcdf1301 --- /dev/null +++ b/packages/svelte/test-app/Pages/Layers/Slow.svelte @@ -0,0 +1 @@ +
Slow layer
diff --git a/packages/svelte/test-app/Pages/Layers/SlowImport.svelte b/packages/svelte/test-app/Pages/Layers/SlowImport.svelte new file mode 100644 index 000000000..6d8df2bfe --- /dev/null +++ b/packages/svelte/test-app/Pages/Layers/SlowImport.svelte @@ -0,0 +1 @@ +
Slow import base
diff --git a/packages/svelte/test-app/Pages/Layers/Waypoint.svelte b/packages/svelte/test-app/Pages/Layers/Waypoint.svelte new file mode 100644 index 000000000..54b5bd82f --- /dev/null +++ b/packages/svelte/test-app/Pages/Layers/Waypoint.svelte @@ -0,0 +1,20 @@ + + +
Waypoint {name}
+ +Go to the next waypoint + + + diff --git a/packages/svelte/test-app/Pages/SSR/Layer.svelte b/packages/svelte/test-app/Pages/SSR/Layer.svelte new file mode 100644 index 000000000..648fbe2a1 --- /dev/null +++ b/packages/svelte/test-app/Pages/SSR/Layer.svelte @@ -0,0 +1,8 @@ + + +
SSR layer
+ diff --git a/packages/svelte/test-app/Pages/SSR/LayerBase.svelte b/packages/svelte/test-app/Pages/SSR/LayerBase.svelte new file mode 100644 index 000000000..d137fd1eb --- /dev/null +++ b/packages/svelte/test-app/Pages/SSR/LayerBase.svelte @@ -0,0 +1 @@ +
SSR layer base
diff --git a/packages/svelte/test-app/Pages/SSR/LayerLoading.svelte b/packages/svelte/test-app/Pages/SSR/LayerLoading.svelte new file mode 100644 index 000000000..307a821a8 --- /dev/null +++ b/packages/svelte/test-app/Pages/SSR/LayerLoading.svelte @@ -0,0 +1 @@ +
SSR loading placeholder
diff --git a/packages/svelte/test-app/Pages/TypeScriptLayoutProps.svelte b/packages/svelte/test-app/Pages/TypeScriptLayoutProps.svelte index 4f22a00c9..16056cada 100644 --- a/packages/svelte/test-app/Pages/TypeScriptLayoutProps.svelte +++ b/packages/svelte/test-app/Pages/TypeScriptLayoutProps.svelte @@ -4,6 +4,8 @@ layoutProps: { title: string showSidebar: boolean + baseChrome?: string + layerChrome?: string } namedLayoutProps: { app: { title: string; theme: 'light' | 'dark' } diff --git a/packages/svelte/test-app/app.ts b/packages/svelte/test-app/app.ts index ef5137f32..dad04444b 100644 --- a/packages/svelte/test-app/app.ts +++ b/packages/svelte/test-app/app.ts @@ -1,11 +1,15 @@ import type { HttpClient, HttpClientOptions, Page } from '@inertiajs/core' import { axiosAdapter, type VisitOptions } from '@inertiajs/core' -import { createInertiaApp, type ResolvedComponent, router } from '@inertiajs/svelte' +import { createInertiaApp, type ResolvedComponent, router, setLayoutProps } from '@inertiajs/svelte' import { hydrate, mount } from 'svelte' +import AdminLayout from './Layouts/AdminLayout.svelte' import AppLayout from './Layouts/AppLayout.svelte' import DefaultLayout from './Layouts/DefaultLayout.svelte' +import Layer from './Layouts/Layer.svelte' +import LoadingBase from './Pages/Layers/LoadingBase.svelte' +import LayerLoading from './Pages/SSR/LayerLoading.svelte' -window.testing = { Inertia: router } +window.testing = { Inertia: router, setLayoutProps } window.resolverReceivedPage = null as Page | null const params = new URLSearchParams(window.location.search) @@ -39,8 +43,28 @@ createInertiaApp({ await new Promise((resolve) => setTimeout(resolve, 50)) } + if (name === 'Layers/Slow') { + // Long enough for a test to supersede the layer visit while it is still resolving + await new Promise((resolve) => setTimeout(resolve, 500)) + } + + if (name === 'Layers/SlowImport') { + // Long enough for a dismissal to supersede a walk landing while its component resolves + await new Promise((resolve) => setTimeout(resolve, 700)) + } + return pages[`./Pages/${name}.svelte`] as ResolvedComponent }, + layer: Layer, + loading: (url: string, page: Page) => { + window.loadingResolved = [url, ...(page.layers ?? []).map((layer) => layer.key)].join('|') + + if (url.startsWith('/ssr/layer-loading-base')) { + return LayerLoading + } + + return url.startsWith('/layers/loading') ? LoadingBase : undefined + }, setup({ el, App, props }) { const isServerRendered = el?.hasAttribute('data-server-rendered') @@ -61,6 +85,9 @@ createInertiaApp({ ...(params.has('withDefaultLayout') && { layout: () => DefaultLayout, }), + ...(params.has('withUrlBasedLayout') && { + layout: (name: string, page) => (page.url?.startsWith('/layers/base') ? AdminLayout : DefaultLayout), + }), ...(params.has('withDefaultAppLayout') && { layout: () => AppLayout, }), diff --git a/packages/svelte/test-app/ssr-auto.ts b/packages/svelte/test-app/ssr-auto.ts index 120a12dde..ffc08efe0 100644 --- a/packages/svelte/test-app/ssr-auto.ts +++ b/packages/svelte/test-app/ssr-auto.ts @@ -1,4 +1,5 @@ import { createInertiaApp, type ResolvedComponent } from '@inertiajs/svelte' +import Layer from './Layouts/Layer.svelte' // This file uses createInertiaApp as a standalone expression (not exported) // The Vite plugin SSR transform will automatically: @@ -7,6 +8,7 @@ import { createInertiaApp, type ResolvedComponent } from '@inertiajs/svelte' // 3. Export a default render function createInertiaApp<{ locale?: string }>({ + layer: Layer, resolve: (name) => { const pages = import.meta.glob('./Pages/SSR/**/*.svelte', { eager: true }) return pages[`./Pages/${name}.svelte`] diff --git a/packages/svelte/test-app/ssr.ts b/packages/svelte/test-app/ssr.ts index 9ba4960f7..38ad843a5 100644 --- a/packages/svelte/test-app/ssr.ts +++ b/packages/svelte/test-app/ssr.ts @@ -1,10 +1,20 @@ import { createInertiaApp, type ResolvedComponent } from '@inertiajs/svelte' import createServer from '@inertiajs/svelte/server' import { render } from 'svelte/server' +import Layer from './Layouts/Layer.svelte' createServer((page) => createInertiaApp({ page, + layer: Layer, + loading: (url: string) => { + if (!url.startsWith('/ssr/layer-loading-base')) { + return undefined + } + + const pages = import.meta.glob('./Pages/SSR/**/*.svelte', { eager: true }) + return pages['./Pages/SSR/LayerLoading.svelte'] + }, serverHead: (page) => page.props.head as string[], resolve: (name) => { const pages = import.meta.glob('./Pages/SSR/**/*.svelte', { eager: true }) diff --git a/packages/svelte/test-app/types.d.ts b/packages/svelte/test-app/types.d.ts index 0939eeb00..0083103cb 100644 --- a/packages/svelte/test-app/types.d.ts +++ b/packages/svelte/test-app/types.d.ts @@ -1,9 +1,13 @@ -import type { Method, Page, PageProps, Router } from '@inertiajs/core' +import type { LayerHandle, Method, Page, PageProps, Router } from '@inertiajs/core' +import { setLayoutProps as adapterSetLayoutProps } from '@inertiajs/svelte' declare global { interface Window { testing: { Inertia: Router + setLayoutProps?: typeof adapterSetLayoutProps + childHandle?: LayerHandle + layerChildHandle?: LayerHandle } initialPage: Page _inertia_request_dump: { @@ -27,6 +31,7 @@ declare global { _inertia_content_layout_id: string | undefined _plugin_global_props: object resolverReceivedPage: Page | null + loadingResolved: string | undefined } interface ImportMeta { diff --git a/packages/vue3/src/Layer.ts b/packages/vue3/src/Layer.ts new file mode 100644 index 000000000..f5806ea49 --- /dev/null +++ b/packages/vue3/src/Layer.ts @@ -0,0 +1,50 @@ +import { + cancelLayer, + layerDialogAttributes, + lockScroll, + observeExit, + raiseLayer, + type LayerShellProps, +} from '@inertiajs/core' +import { defineComponent, h, onBeforeUnmount, onMounted, PropType, ref, watch } from 'vue' + +export default defineComponent({ + name: 'Layer', + props: { + open: { type: Boolean, required: true }, + index: { type: Number, required: true }, + isTop: { type: Boolean, required: true }, + type: { type: String as PropType, required: true }, + close: { type: Function as PropType<() => void>, required: true }, + done: { type: Function, required: true }, + label: { type: String, required: false }, + }, + setup(props, { slots }) { + const dialog = ref(null) + const exit = observeExit( + () => dialog.value, + () => props.done(), + ) + let releaseScroll: (() => void) | null = null + + onMounted(() => { + raiseLayer(dialog.value!, props.isTop) + releaseScroll = lockScroll() + exit.toggle(props.open) + }) + + onBeforeUnmount(() => { + exit.teardown() + releaseScroll?.() + }) + + watch( + () => props.open, + (open) => exit.toggle(open), + ) + + const onCancel = (event: Event) => cancelLayer(event, props) + + return () => h('dialog', { ref: dialog, ...layerDialogAttributes(props), onCancel }, slots.default?.()) + }, +}) diff --git a/packages/vue3/src/app.ts b/packages/vue3/src/app.ts index a29f601fb..aadcf7a3b 100755 --- a/packages/vue3/src/app.ts +++ b/packages/vue3/src/app.ts @@ -1,16 +1,24 @@ import { createHeadManager, + emptyLayoutSlot, HeadManager, HeadManagerOnUpdateCallback, HeadManagerTitleCallback, isPropsObject, isPropsObjectOrCallback, + layerShellProps, + layerTransitionName, + layoutPageOf, + LayoutSlot, + LoadingResolver, normalizeLayouts, Page, PageProps, + ResolvedLayer, resolveServerHead, router, SharedPageProps, + topPageOf, type ServerHeadOption, } from '@inertiajs/core' import { @@ -19,17 +27,30 @@ import { DefineComponent, defineComponent, h, + hasInjectionContext, + InjectionKey, + inject, markRaw, Plugin, PropType, + provide, reactive, ref, + Ref, shallowRef, + toRef, } from 'vue' -import { state as layoutPropsState, resetLayoutProps } from './layoutProps' +import Layer from './Layer' +import { + layerState as layerPropsForLayers, + retainLayerLayoutProps, + state as layoutPropsState, + resetLayoutProps, +} from './layoutProps' import remember from './remember' import { VuePageHandlerArgs } from './types' import useForm from './useForm' +import { layerIdKey } from './useLayer' type LayoutComponent = DefineComponent | Component @@ -68,10 +89,13 @@ function isRenderFunction(value: unknown): boolean { export interface InertiaAppProps { initialPage: Page initialComponent?: DefineComponent + initialLayers?: ResolvedLayer[] resolveComponent?: (name: string, page?: Page) => DefineComponent | Promise titleCallback?: HeadManagerTitleCallback onHeadUpdate?: HeadManagerOnUpdateCallback defaultLayout?: (name: string, page: Page) => unknown + layer?: Component + resolveLoading?: LoadingResolver serverHead?: ServerHeadOption } @@ -79,11 +103,29 @@ export type InertiaApp = DefineComponent const component = ref(undefined) const page = ref() -let pageAccessor: Page | null = null +const layers = shallowRef[] | undefined>(undefined) +let pageAccessorRef: Page | null = null +const layerAccessors = new WeakMap, Page>() const layout = shallowRef(null) const key = ref(undefined) export let headManager: HeadManager +const layerPageKey: InjectionKey> = Symbol('inertiaLayerPage') + +const LayerPageProvider = defineComponent({ + name: 'LayerPageProvider', + props: { + page: { type: Object as PropType, required: true }, + layerId: { type: String, required: true }, + }, + setup(props, { slots }) { + provide(layerPageKey, toRef(props, 'page')) + provide(layerIdKey, props.layerId) + + return () => slots.default?.() + }, +}) + const App: InertiaApp = defineComponent({ name: 'Inertia', props: { @@ -95,6 +137,10 @@ const App: InertiaApp = defineComponent({ type: Object as PropType, required: false, }, + initialLayers: { + type: Array as PropType[]>, + required: false, + }, resolveComponent: { type: Function as PropType<(name: string, page?: Page) => DefineComponent | Promise>, required: false, @@ -113,6 +159,15 @@ const App: InertiaApp = defineComponent({ type: Function as PropType<(name: string, page: Page) => unknown>, required: false, }, + layer: { + type: [Object, Function] as PropType, + required: false, + default: Layer, + }, + resolveLoading: { + type: Function as PropType, + required: false, + }, serverHead: { type: [Boolean, String, Function] as PropType, required: false, @@ -121,36 +176,45 @@ const App: InertiaApp = defineComponent({ setup({ initialPage, initialComponent, + initialLayers, resolveComponent, titleCallback, onHeadUpdate, defaultLayout, + layer, + resolveLoading, serverHead, }: InertiaAppProps) { component.value = initialComponent ? markRaw(initialComponent) : undefined page.value = { ...initialPage, flash: initialPage.flash ?? {} } + layers.value = initialLayers?.map((layer) => ({ ...layer, component: markRaw(layer.component) })) key.value = undefined const isServer = typeof window === 'undefined' headManager = createHeadManager( isServer, - (title: string) => (titleCallback ? titleCallback(title, page.value!) : title), + (title: string) => (titleCallback ? titleCallback(title, topPageOf(page.value!)) : title), onHeadUpdate || (() => {}), resolveServerHead(initialPage, serverHead), + () => page.value?.layers ?? [], ) if (!isServer) { router.init({ initialPage, resolveComponent: resolveComponent!, + resolveLoading, swapComponent: async (options: VuePageHandlerArgs) => { if (!options.preserveState) { resetLayoutProps() } - component.value = markRaw(options.component) + retainLayerLayoutProps((options.layers ?? []).map((layer) => layer.id)) + + component.value = options.component ? markRaw(options.component) : undefined page.value = options.page + layers.value = options.layers?.map((layer) => ({ ...layer, component: markRaw(layer.component) })) key.value = options.preserveState ? key.value : Date.now() }, onFlash: (flash) => { @@ -166,82 +230,130 @@ const App: InertiaApp = defineComponent({ router.on('clientVisit', syncServerHead) } - return () => { - if (component.value) { - component.value.inheritAttrs = !!component.value.inheritAttrs + const baseLayoutProps = () => (isServer ? emptyLayoutSlot : layoutPropsState.value) + const layerLayoutProps = (layerId: string) => + isServer ? emptyLayoutSlot : (layerPropsForLayers.value.get(layerId) ?? emptyLayoutSlot) + + const wrapInLayout = ( + component: DefineComponent, + page: Page, + child: ReturnType, + dynamicProps: () => LayoutSlot, + ): ReturnType => { + if (component.layout && isRenderFunction(component.layout)) { + return (component.layout as Function)(h, child) + } - const child = h(component.value, { - ...page.value!.props, - key: key.value, - }) + let effectiveLayout: unknown + let callbackProps: Record | null = null + const layoutValue = component.layout - if (layout.value) { - component.value.layout = layout.value - layout.value = null + if ( + typeof layoutValue === 'function' && + (layoutValue as Function).length <= 1 && + typeof (layoutValue as Function).prototype === 'undefined' + ) { + const result = (layoutValue as Function)(page.props) + + if (isPropsObjectOrCallback(result, isComponent)) { + effectiveLayout = defaultLayout?.(page.component, page) + callbackProps = result as Record + } else { + effectiveLayout = result } + } else if (isPropsObject(layoutValue, isComponent)) { + effectiveLayout = defaultLayout?.(page.component, page) + callbackProps = layoutValue as Record + } else { + effectiveLayout = layoutValue ?? defaultLayout?.(page.component, page) + } - if (component.value.layout && isRenderFunction(component.value.layout)) { - return (component.value.layout as Function)(h, child) + if (effectiveLayout) { + let layouts = normalizeLayouts( + effectiveLayout, + isComponent, + component.layout && !callbackProps ? isRenderFunction : undefined, + ) + + if (callbackProps) { + layouts = layouts.map((l) => ({ ...l, props: { ...l.props, ...callbackProps } })) } - let effectiveLayout: unknown - let callbackProps: Record | null = null - const layoutValue = component.value.layout - - if ( - typeof layoutValue === 'function' && - (layoutValue as Function).length <= 1 && - typeof (layoutValue as Function).prototype === 'undefined' - ) { - const result = (layoutValue as Function)(page.value!.props) - - if (isPropsObjectOrCallback(result, isComponent)) { - effectiveLayout = defaultLayout?.(page.value!.component, page.value!) - callbackProps = result as Record - } else { - effectiveLayout = result - } - } else if (isPropsObject(layoutValue, isComponent)) { - effectiveLayout = defaultLayout?.(page.value!.component, page.value!) - callbackProps = layoutValue as Record - } else { - effectiveLayout = layoutValue ?? defaultLayout?.(page.value!.component, page.value!) + if (layouts.length > 0) { + const slot = dynamicProps() + + return layouts.reduceRight((childNode, layout) => { + const layoutComponent = layout.component as DefineComponent + layoutComponent.inheritAttrs = !!layoutComponent.inheritAttrs + + return h( + layoutComponent, + { + ...page.props, + ...layout.props, + ...slot.shared, + ...(layout.name ? slot.named[layout.name] || {} : {}), + }, + () => childNode, + ) + }, child) } + } - if (effectiveLayout) { - let layouts = normalizeLayouts( - effectiveLayout, - isComponent, - component.value.layout && !callbackProps ? isRenderFunction : undefined, - ) + return child + } - if (callbackProps) { - layouts = layouts.map((l) => ({ ...l, props: { ...l.props, ...callbackProps } })) - } + const renderTier = (component: DefineComponent, props: PageProps, key: number | undefined) => { + component.inheritAttrs = !!component.inheritAttrs - if (layouts.length > 0) { - const dynamicProps = isServer ? { shared: {}, named: {} } : layoutPropsState.value - - return layouts.reduceRight((childNode, layout) => { - const layoutComponent = layout.component as DefineComponent - layoutComponent.inheritAttrs = !!layoutComponent.inheritAttrs - - return h( - layoutComponent, - { - ...page.value!.props, - ...layout.props, - ...dynamicProps.shared, - ...(layout.name ? dynamicProps.named[layout.name] || {} : {}), - }, - () => childNode, - ) - }, child) - } - } + return h(component, { ...props, key }) + } + + const renderBase = () => { + if (!component.value) { + return + } + + const child = renderTier(component.value, page.value!.props, key.value) - return child + if (layout.value) { + component.value.layout = layout.value + layout.value = null } + + return wrapInLayout(component.value, page.value!, child, baseLayoutProps) + } + + // Always a fragment, even with an empty stack. A root that switches between a single vnode and + // a fragment fails isSameVNodeType, so opening the first layer would remount the page beneath + // and lose its scroll position, form state and playing media. + return () => { + const stack = layers.value ?? [] + const LayerShell = layer ?? Layer + + return [ + renderBase(), + ...stack.map((layer, index) => { + const layoutPage = layoutPageOf(layer) + const content = () => + wrapInLayout( + layer.component, + layoutPage, + renderTier(layer.component, layoutPage.props, layer.renderKey), + () => layerLayoutProps(layer.id), + ) + + return h(LayerPageProvider, { key: layer.id, page: layer.page, layerId: layer.id }, () => + h(LayerShell, layerShellProps(layer, index, stack.length), () => + h( + 'div', + { 'data-layer-id': layer.id, style: { viewTransitionName: layerTransitionName(layer.id) } }, + content(), + ), + ), + ) + }), + ] } }, }) @@ -259,26 +371,43 @@ export const plugin: Plugin = { }, } +const pageKeys = [ + 'props', + 'url', + 'component', + 'version', + 'clearHistory', + 'deferredProps', + 'rescuedProps', + 'mergeProps', + 'prependProps', + 'deepMergeProps', + 'matchPropsOn', + 'rememberedState', + 'encryptHistory', + 'scrollProps', + 'flash', + 'layers', +] as const + +const pageAccessor = (source: () => Page | undefined): Page => + reactive(Object.fromEntries(pageKeys.map((key) => [key, computed(() => source()?.[key])]))) as unknown as Page + export function usePage(): Page { - if (!pageAccessor) { - pageAccessor = reactive({ - props: computed(() => page.value?.props), - url: computed(() => page.value?.url), - component: computed(() => page.value?.component), - version: computed(() => page.value?.version), - clearHistory: computed(() => page.value?.clearHistory), - deferredProps: computed(() => page.value?.deferredProps), - rescuedProps: computed(() => page.value?.rescuedProps), - mergeProps: computed(() => page.value?.mergeProps), - prependProps: computed(() => page.value?.prependProps), - deepMergeProps: computed(() => page.value?.deepMergeProps), - matchPropsOn: computed(() => page.value?.matchPropsOn), - rememberedState: computed(() => page.value?.rememberedState), - encryptHistory: computed(() => page.value?.encryptHistory), - scrollProps: computed(() => page.value?.scrollProps), - flash: computed(() => page.value?.flash), - }) as Page + const layerPage = hasInjectionContext() ? inject | undefined>(layerPageKey, undefined) : undefined + + if (layerPage) { + let layerAccessor = layerAccessors.get(layerPage) + + if (!layerAccessor) { + layerAccessor = pageAccessor(() => layerPage.value) + layerAccessors.set(layerPage, layerAccessor) + } + + return layerAccessor as Page } - return pageAccessor as Page + pageAccessorRef ??= pageAccessor(() => page.value) + + return pageAccessorRef as Page } diff --git a/packages/vue3/src/createInertiaApp.ts b/packages/vue3/src/createInertiaApp.ts index 2785316a6..6b475394f 100644 --- a/packages/vue3/src/createInertiaApp.ts +++ b/packages/vue3/src/createInertiaApp.ts @@ -8,16 +8,19 @@ import { getInitialPageFromDOM, http as httpModule, InertiaAppSSRResponse, + LoadingOption, + normalizeLoading, Page, PageProps, + resolveInitialPage, router, setupProgress, SharedPageProps, } from '@inertiajs/core' -import { createApp, createSSRApp, DefineComponent, h, Plugin, App as VueApp } from 'vue' +import { Component, createApp, createSSRApp, DefineComponent, h, isVNode, Plugin, App as VueApp } from 'vue' import App, { InertiaApp, InertiaAppProps, plugin } from './app' import { config } from './index' -import { VueInertiaAppConfig } from './types' +import { LayerComponent, VueInertiaAppConfig } from './types' type ComponentResolver = ( name: string, @@ -43,6 +46,8 @@ type InertiaAppOptionsForCSR = CreateInertiaAppOp void, VueInertiaAppConfig > & { + layer?: LayerComponent + loading?: LoadingOption withApp?: never } @@ -54,6 +59,8 @@ type InertiaAppOptionsForSSR = CreateInertiaAppOp VueInertiaAppConfig > & { render: (app: VueApp) => Promise + layer?: LayerComponent + loading?: LoadingOption withApp?: never } @@ -68,6 +75,8 @@ type InertiaAppOptionsAuto = Omit< > & { page?: Page render?: undefined + layer?: LayerComponent + loading?: LoadingOption } & ( | { setup?: undefined; withApp?: VueWithApp } | { setup: (options: SetupOptions) => VueApp | void; withApp?: never } @@ -102,6 +111,8 @@ export default async function createInertiaApp Promise.resolve(resolve!(name, page)).then((module) => module.default || module) + const resolveLoading = normalizeLoading(loading, { rendered: isVNode }) + // SSR render function factory - when on server without page/render, return a render function // This is used by the Vite plugin's SSR transform if (isServer && !page && !render) { return async (page: Page, renderToString: RenderToString) => { let head: string[] = [] - const initialComponent = await resolveComponent(page.component, page) + const { + page: initialPage, + component: initialComponent, + layers: initialLayers, + } = await resolveInitialPage(page, resolveComponent, resolveLoading) const props: InertiaAppProps = { - initialPage: page, + initialPage, initialComponent, + initialLayers, resolveComponent, titleCallback: title, onHeadUpdate: (elements: string[]) => (head = elements), defaultLayout: layout, + layer, + resolveLoading, serverHead, } @@ -172,21 +192,24 @@ export default async function createInertiaApp>(id)! + const initialResponse = page || getInitialPageFromDOM>(id)! let head: string[] = [] const vueApp = await Promise.all([ - resolveComponent(initialPage.component, initialPage), + resolveInitialPage(initialResponse, resolveComponent, resolveLoading), router.decryptHistory().catch(() => {}), - ]).then(([initialComponent]) => { + ]).then(([{ page: initialPage, component: initialComponent, layers: initialLayers }]) => { const props: InertiaAppProps = { initialPage, initialComponent, + initialLayers, resolveComponent, titleCallback: title, onHeadUpdate: isServer ? (elements: string[]) => (head = elements) : undefined, defaultLayout: layout, + layer, + resolveLoading, serverHead, } @@ -238,7 +261,7 @@ export default async function createInertiaApp, setup(props, { slots }) { + const layerId = useLayerId() const reloading = ref(false) const activeReloads = new Set() const page = usePage() @@ -30,11 +32,7 @@ export default defineComponent({ removeStartListener = router.on('start', (e) => { const visit = e.detail.visit - if ( - visit.preserveState === true && - isSameUrlWithoutQueryOrHash(visit.url, window.location) && - partialReloadRequestsSomeProps(visit, keys.value) - ) { + if (partialReloadFillsDeferred(visit, { layerId, url: page.url }, keys.value)) { activeReloads.add(visit) reloading.value = true } diff --git a/packages/vue3/src/head.ts b/packages/vue3/src/head.ts index 89fd35bed..b5d52e72e 100644 --- a/packages/vue3/src/head.ts +++ b/packages/vue3/src/head.ts @@ -1,6 +1,7 @@ import { escape } from 'es-toolkit/compat' -import { defineComponent, DefineComponent, onBeforeUnmount, VNode } from 'vue' +import { defineComponent, DefineComponent, inject, onBeforeUnmount, VNode } from 'vue' import { headManager } from './app' +import { layerIdKey } from './useLayer' export type InertiaHead = DefineComponent<{ title?: string @@ -147,7 +148,8 @@ const Head: InertiaHead = defineComponent({ }, }, setup(props, { slots }) { - const provider = headManager.createProvider() + const layerId = inject(layerIdKey, undefined) + const provider = headManager.createProvider(layerId) onBeforeUnmount(() => { provider.disconnect() diff --git a/packages/vue3/src/index.ts b/packages/vue3/src/index.ts index 6ab59d516..11edb0af4 100755 --- a/packages/vue3/src/index.ts +++ b/packages/vue3/src/index.ts @@ -8,11 +8,13 @@ export { default as Deferred } from './deferred' export { createForm, default as Form, useFormContext } from './form' export { default as Head } from './head' export { default as InfiniteScroll } from './infiniteScroll' +export { default as Layer } from './Layer' export { resetLayoutProps, setLayoutProps } from './layoutProps' export { InertiaLinkProps, default as Link } from './link' -export { type LayoutCallback, type VueInertiaAppConfig, type VuePageHandlerArgs } from './types' +export { type LayerComponent, type LayoutCallback, type VueInertiaAppConfig, type VuePageHandlerArgs } from './types' export { InertiaForm, InertiaFormProps, InertiaPrecognitiveForm, default as useForm } from './useForm' export { default as useHttp } from './useHttp' +export { default as useLayer } from './useLayer' export { default as usePoll } from './usePoll' export { default as usePrefetch } from './usePrefetch' export { default as useRemember } from './useRemember' diff --git a/packages/vue3/src/infiniteScroll.ts b/packages/vue3/src/infiniteScroll.ts index 4d9d6c767..2c2307aeb 100644 --- a/packages/vue3/src/infiniteScroll.ts +++ b/packages/vue3/src/infiniteScroll.ts @@ -7,8 +7,21 @@ import { ReloadOptions, useInfiniteScroll, } from '@inertiajs/core' -import { computed, defineComponent, Fragment, h, onMounted, onUnmounted, PropType, ref, SlotsType, watch } from 'vue' +import { + computed, + defineComponent, + Fragment, + h, + inject, + onMounted, + onUnmounted, + PropType, + ref, + SlotsType, + watch, +} from 'vue' import { usePage } from './app' +import { layerIdKey } from './useLayer' // Vue-specific element resolver const resolveHTMLElement = ( @@ -130,6 +143,7 @@ const InfiniteScroll = defineComponent({ elementManager, flush: flushInfiniteScroll, } = useInfiniteScroll({ + layerId: inject(layerIdKey, undefined), // Data getPropName: () => props.data, inReverseMode: () => props.reverse, diff --git a/packages/vue3/src/layoutProps.ts b/packages/vue3/src/layoutProps.ts index 1c5f2e3c9..f36f59b88 100644 --- a/packages/vue3/src/layoutProps.ts +++ b/packages/vue3/src/layoutProps.ts @@ -1,27 +1,24 @@ -import { createLayoutPropsStore, type LayoutProps, type NamedLayoutProps } from '@inertiajs/core' +import { createLayoutPropsStore, type LayoutSlot } from '@inertiajs/core' import { ref } from 'vue' const store = createLayoutPropsStore() export const state = ref(store.get()) +export const layerState = ref(new Map()) + store.subscribe(() => { state.value = store.get() + layerState.value = new Map(store.layerIds().map((id) => [id, store.getForLayer(id)])) }) -export function setLayoutProps(props: Partial): void -export function setLayoutProps(name: K, props: Partial): void -export function setLayoutProps(props: Partial>): void -export function setLayoutProps(name: string, props: Partial>): void -export function setLayoutProps(nameOrProps: string | Record, props?: Record): void { - if (typeof nameOrProps === 'string') { - store.setFor(nameOrProps, props!) - } else { - store.set(nameOrProps) - } -} +export const setLayoutProps = store.set export function resetLayoutProps(): void { store.reset() state.value = store.get() } + +export function retainLayerLayoutProps(ids: string[]): void { + store.retainLayers(ids) +} diff --git a/packages/vue3/src/link.ts b/packages/vue3/src/link.ts index 451dafd19..d1b01b875 100755 --- a/packages/vue3/src/link.ts +++ b/packages/vue3/src/link.ts @@ -13,6 +13,7 @@ import { } from '@inertiajs/core' import { Component, computed, defineComponent, DefineComponent, h, onMounted, onUnmounted, PropType, ref } from 'vue' import { config } from '.' +import { useLayerId } from './useLayer' const noop = () => {} @@ -148,6 +149,7 @@ const Link: InertiaLink = defineComponent({ }, }, setup(props, { slots, attrs }) { + const layerId = useLayerId() const inFlightCount = ref(0) const hoverTimeout = ref>() @@ -240,6 +242,7 @@ const Link: InertiaLink = defineComponent({ }) const baseParams = computed(() => ({ + layerId, data: data.value, method: method.value, replace: props.replace, diff --git a/packages/vue3/src/remember.ts b/packages/vue3/src/remember.ts index 5fc7669d3..9b749875e 100755 --- a/packages/vue3/src/remember.ts +++ b/packages/vue3/src/remember.ts @@ -1,6 +1,7 @@ import { router } from '@inertiajs/core' import { cloneDeep } from 'es-toolkit' -import { ComponentOptions } from 'vue' +import { ComponentOptions, inject } from 'vue' +import { layerIdKey } from './useLayer' const remember: ComponentOptions = { created() { @@ -8,6 +9,8 @@ const remember: ComponentOptions = { return } + const layerId = inject(layerIdKey, undefined) + if (Array.isArray(this.$options.remember)) { this.$options.remember = { data: this.$options.remember } } @@ -25,7 +28,7 @@ const remember: ComponentOptions = { ? this.$options.remember.key.call(this) : this.$options.remember.key - const restored = router.restore(rememberKey) as Record | undefined + const restored = router.restore(rememberKey, layerId) as Record | undefined const rememberable = this.$options.remember.data.filter((key: string) => { return !(this[key] !== null && typeof this[key] === 'object' && this[key].__rememberable === false) @@ -57,6 +60,7 @@ const remember: ComponentOptions = { {}, ), rememberKey, + layerId, ) }, { immediate: true, deep: true }, diff --git a/packages/vue3/src/types.ts b/packages/vue3/src/types.ts index a4519aef9..f2a9eeba5 100644 --- a/packages/vue3/src/types.ts +++ b/packages/vue3/src/types.ts @@ -1,5 +1,6 @@ import { createHeadManager, + type LayerShellProps, type LayoutCallbackReturn, Page, PageHandler, @@ -13,6 +14,8 @@ import useForm from './useForm' export type VuePageHandlerArgs = Parameters>[0] export type VueInertiaAppConfig = {} +export type LayerComponent = Component + export type LayoutCallback = (props: SharedPageProps) => LayoutCallbackReturn export type LayoutRenderFunction = (h: (component: Component, children: Component[]) => VNode, page: Component) => VNode diff --git a/packages/vue3/src/useForm.ts b/packages/vue3/src/useForm.ts index 725738a50..7958eb9f7 100644 --- a/packages/vue3/src/useForm.ts +++ b/packages/vue3/src/useForm.ts @@ -156,6 +156,7 @@ export default function useForm>( setRememberExcludeKeys, resetBeforeSubmit, finishProcessing, + layerId, } = useFormState({ data, rememberKey, @@ -178,6 +179,7 @@ export default function useForm>( resetDefaultsCalledInOnSuccess() const _options: VisitOptions = { + layerId, ...options, onCancelToken: (token) => { cancelToken = token diff --git a/packages/vue3/src/useFormState.ts b/packages/vue3/src/useFormState.ts index 23dc24153..00ed0253f 100644 --- a/packages/vue3/src/useFormState.ts +++ b/packages/vue3/src/useFormState.ts @@ -23,6 +23,7 @@ import { } from 'laravel-precognition' import { reactive, watch } from 'vue' import { config } from '.' +import { useLayerId } from './useLayer' type PrecognitionValidationConfig = ValidationConfig & { only?: TKeys[] | Iterable | ArrayLike @@ -102,19 +103,21 @@ export interface UseFormStateReturn { resetBeforeSubmit: () => void finishProcessing: () => void withAllErrors: { enabled: () => boolean; enable: () => void } + layerId: string | undefined } export default function useFormState( options: UseFormStateOptions, ): UseFormStateReturn { const { data: dataOption, rememberKey } = options + const layerId = useLayerId() let { precognitionEndpoint } = options const isDataFunction = typeof dataOption === 'function' const resolveData = () => (isDataFunction ? (dataOption as () => TForm)() : dataOption) const restored = rememberKey - ? (router.restore(rememberKey) as { data: TForm; errors: Record, ErrorValue> } | null) + ? (router.restore(rememberKey, layerId) as { data: TForm; errors: Record, ErrorValue> } | null) : null const initialData = restored?.data ?? cloneDeep(resolveData()) @@ -378,11 +381,11 @@ export default function useFormState( return } - const storedData = router.restore(rememberKey) + const storedData = router.restore(rememberKey, layerId) const newData = cloneDeep((newValue as unknown as InternalRememberState).__remember()) if (!isEqual(storedData, newData)) { - router.remember(newData, rememberKey) + router.remember(newData, rememberKey, layerId) } }, { immediate: true, deep: true }, @@ -394,6 +397,7 @@ export default function useFormState( return { form: typedForm, + layerId, setDefaults: (newDefaults: TForm) => { defaults = newDefaults }, diff --git a/packages/vue3/src/useLayer.ts b/packages/vue3/src/useLayer.ts new file mode 100644 index 000000000..4ecbd387b --- /dev/null +++ b/packages/vue3/src/useLayer.ts @@ -0,0 +1,10 @@ +import { createLayerApi, type LayerApi } from '@inertiajs/core' +import { hasInjectionContext, inject, type InjectionKey } from 'vue' + +export const layerIdKey: InjectionKey = Symbol('inertiaLayerId') + +export const useLayerId = (): string | undefined => (hasInjectionContext() ? inject(layerIdKey, undefined) : undefined) + +export default function useLayer(): LayerApi { + return createLayerApi(useLayerId()) +} diff --git a/packages/vue3/src/usePoll.ts b/packages/vue3/src/usePoll.ts index 0abd0bbdf..7ce8e95b3 100644 --- a/packages/vue3/src/usePoll.ts +++ b/packages/vue3/src/usePoll.ts @@ -1,5 +1,6 @@ -import { PollOptions, ReloadOptions, router } from '@inertiajs/core' +import { PollOptions, ReloadOptions } from '@inertiajs/core' import { onMounted, onUnmounted, ref, Ref } from 'vue' +import useLayer from './useLayer' export default function usePoll( interval: number, @@ -15,7 +16,7 @@ export default function usePoll( } { const autoStart = options.autoStart ?? true - const { stop, start, destroy } = router.poll(interval, requestOptions, { + const { stop, start, destroy } = useLayer().poll(interval, requestOptions, { ...options, autoStart: false, }) diff --git a/packages/vue3/src/useRemember.ts b/packages/vue3/src/useRemember.ts index 165e95821..1b92ad61c 100644 --- a/packages/vue3/src/useRemember.ts +++ b/packages/vue3/src/useRemember.ts @@ -1,6 +1,7 @@ import { router } from '@inertiajs/core' import { cloneDeep } from 'es-toolkit' -import { isReactive, reactive, ref, Ref, watch } from 'vue' +import { inject, isReactive, reactive, ref, Ref, watch } from 'vue' +import { layerIdKey } from './useLayer' export default function useRemember( data: T & { __rememberable?: boolean; __remember?: Function; __restore?: Function }, @@ -10,7 +11,8 @@ export default function useRemember( return data } - const restored = router.restore(key) + const layerId = inject(layerIdKey, undefined) + const restored = router.restore(key, layerId) const type = isReactive(data) ? reactive : ref const hasCallbacks = typeof data.__remember === 'function' && typeof data.__restore === 'function' const remembered = type( @@ -20,7 +22,7 @@ export default function useRemember( watch( remembered, (newValue) => { - router.remember(cloneDeep(hasCallbacks ? data.__remember!() : newValue), key) + router.remember(cloneDeep(hasCallbacks ? data.__remember!() : newValue), key, layerId) }, { immediate: true, deep: true }, ) diff --git a/packages/vue3/src/whenVisible.ts b/packages/vue3/src/whenVisible.ts index 0e0b24a88..3424ab168 100644 --- a/packages/vue3/src/whenVisible.ts +++ b/packages/vue3/src/whenVisible.ts @@ -2,6 +2,7 @@ import { ReloadOptions, router } from '@inertiajs/core' import { get } from 'es-toolkit/compat' import { computed, defineComponent, h, nextTick, onUnmounted, PropType, ref, SlotsType, watch } from 'vue' import { usePage } from './app' +import { useLayerId } from './useLayer' export default defineComponent({ name: 'WhenVisible', @@ -30,6 +31,7 @@ export default defineComponent({ }, }, setup(props, { slots }) { + const layerId = useLayerId() const loaded = ref(false) const fetching = ref(false) const observer = ref(null) @@ -76,6 +78,7 @@ export default defineComponent({ const reloadParams = getReloadParams() router.reload({ + layerId, ...reloadParams, onStart: (e) => { fetching.value = true diff --git a/packages/vue3/test-app/Layouts/AdminLayout.vue b/packages/vue3/test-app/Layouts/AdminLayout.vue new file mode 100644 index 000000000..128b727e9 --- /dev/null +++ b/packages/vue3/test-app/Layouts/AdminLayout.vue @@ -0,0 +1,8 @@ + + + diff --git a/packages/vue3/test-app/Layouts/DefaultLayout.vue b/packages/vue3/test-app/Layouts/DefaultLayout.vue index 3fc8af1d0..3dce00ca3 100644 --- a/packages/vue3/test-app/Layouts/DefaultLayout.vue +++ b/packages/vue3/test-app/Layouts/DefaultLayout.vue @@ -1,8 +1,13 @@ - + diff --git a/packages/vue3/test-app/Layouts/Layer.vue b/packages/vue3/test-app/Layouts/Layer.vue new file mode 100644 index 000000000..7bb3eeaf0 --- /dev/null +++ b/packages/vue3/test-app/Layouts/Layer.vue @@ -0,0 +1,19 @@ + + + diff --git a/packages/vue3/test-app/Pages/History/NonSerializable.vue b/packages/vue3/test-app/Pages/History/NonSerializable.vue new file mode 100644 index 000000000..f7f20e293 --- /dev/null +++ b/packages/vue3/test-app/Pages/History/NonSerializable.vue @@ -0,0 +1,15 @@ + + + diff --git a/packages/vue3/test-app/Pages/History/NonSerializableTarget.vue b/packages/vue3/test-app/Pages/History/NonSerializableTarget.vue new file mode 100644 index 000000000..561162e0d --- /dev/null +++ b/packages/vue3/test-app/Pages/History/NonSerializableTarget.vue @@ -0,0 +1,19 @@ + + + diff --git a/packages/vue3/test-app/Pages/Layers/Base.vue b/packages/vue3/test-app/Pages/Layers/Base.vue new file mode 100644 index 000000000..ed0a1ee6c --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/Base.vue @@ -0,0 +1,108 @@ + + + diff --git a/packages/vue3/test-app/Pages/Layers/ChainInner.vue b/packages/vue3/test-app/Pages/Layers/ChainInner.vue new file mode 100644 index 000000000..47631d660 --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/ChainInner.vue @@ -0,0 +1,8 @@ + + + diff --git a/packages/vue3/test-app/Pages/Layers/ChainOuter.vue b/packages/vue3/test-app/Pages/Layers/ChainOuter.vue new file mode 100644 index 000000000..f050a5e45 --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/ChainOuter.vue @@ -0,0 +1,8 @@ + + + diff --git a/packages/vue3/test-app/Pages/Layers/Child.vue b/packages/vue3/test-app/Pages/Layers/Child.vue new file mode 100644 index 000000000..67626de3f --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/Child.vue @@ -0,0 +1,19 @@ + + + diff --git a/packages/vue3/test-app/Pages/Layers/Deferred.vue b/packages/vue3/test-app/Pages/Layers/Deferred.vue new file mode 100644 index 000000000..fd9c885f4 --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/Deferred.vue @@ -0,0 +1,18 @@ + + + diff --git a/packages/vue3/test-app/Pages/Layers/Guarded.vue b/packages/vue3/test-app/Pages/Layers/Guarded.vue new file mode 100644 index 000000000..d2dfda89b --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/Guarded.vue @@ -0,0 +1,3 @@ + diff --git a/packages/vue3/test-app/Pages/Layers/Headed.vue b/packages/vue3/test-app/Pages/Layers/Headed.vue new file mode 100644 index 000000000..cec2e5f7b --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/Headed.vue @@ -0,0 +1,8 @@ + + + diff --git a/packages/vue3/test-app/Pages/Layers/HeadedBase.vue b/packages/vue3/test-app/Pages/Layers/HeadedBase.vue new file mode 100644 index 000000000..2c3df0120 --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/HeadedBase.vue @@ -0,0 +1,8 @@ + + + diff --git a/packages/vue3/test-app/Pages/Layers/Infinite.vue b/packages/vue3/test-app/Pages/Layers/Infinite.vue new file mode 100644 index 000000000..5a3e56a95 --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/Infinite.vue @@ -0,0 +1,25 @@ + + + diff --git a/packages/vue3/test-app/Pages/Layers/LoadingBase.vue b/packages/vue3/test-app/Pages/Layers/LoadingBase.vue new file mode 100644 index 000000000..38e500c79 --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/LoadingBase.vue @@ -0,0 +1,3 @@ + diff --git a/packages/vue3/test-app/Pages/Layers/Local.vue b/packages/vue3/test-app/Pages/Layers/Local.vue new file mode 100644 index 000000000..ae9eb8cae --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/Local.vue @@ -0,0 +1,9 @@ + + + diff --git a/packages/vue3/test-app/Pages/Layers/Login.vue b/packages/vue3/test-app/Pages/Layers/Login.vue new file mode 100644 index 000000000..b4a9e9d6d --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/Login.vue @@ -0,0 +1,13 @@ + + + diff --git a/packages/vue3/test-app/Pages/Layers/ModuleScope.vue b/packages/vue3/test-app/Pages/Layers/ModuleScope.vue new file mode 100644 index 000000000..db46f2c21 --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/ModuleScope.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/packages/vue3/test-app/Pages/Layers/Panel.vue b/packages/vue3/test-app/Pages/Layers/Panel.vue new file mode 100644 index 000000000..4529d22fa --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/Panel.vue @@ -0,0 +1,118 @@ + + + diff --git a/packages/vue3/test-app/Pages/Layers/Prompt.vue b/packages/vue3/test-app/Pages/Layers/Prompt.vue new file mode 100644 index 000000000..185035634 --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/Prompt.vue @@ -0,0 +1,13 @@ + + + diff --git a/packages/vue3/test-app/Pages/Layers/ScrollBase.vue b/packages/vue3/test-app/Pages/Layers/ScrollBase.vue new file mode 100644 index 000000000..aec27cbee --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/ScrollBase.vue @@ -0,0 +1,6 @@ + diff --git a/packages/vue3/test-app/Pages/Layers/ScrollLayer.vue b/packages/vue3/test-app/Pages/Layers/ScrollLayer.vue new file mode 100644 index 000000000..7b4c75380 --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/ScrollLayer.vue @@ -0,0 +1,6 @@ + diff --git a/packages/vue3/test-app/Pages/Layers/Settings.vue b/packages/vue3/test-app/Pages/Layers/Settings.vue new file mode 100644 index 000000000..9680312b2 --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/Settings.vue @@ -0,0 +1,12 @@ + + + diff --git a/packages/vue3/test-app/Pages/Layers/Slow.vue b/packages/vue3/test-app/Pages/Layers/Slow.vue new file mode 100644 index 000000000..e0773b387 --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/Slow.vue @@ -0,0 +1,3 @@ + diff --git a/packages/vue3/test-app/Pages/Layers/SlowImport.vue b/packages/vue3/test-app/Pages/Layers/SlowImport.vue new file mode 100644 index 000000000..3d7479887 --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/SlowImport.vue @@ -0,0 +1,3 @@ + diff --git a/packages/vue3/test-app/Pages/Layers/Waypoint.vue b/packages/vue3/test-app/Pages/Layers/Waypoint.vue new file mode 100644 index 000000000..32f87b3bc --- /dev/null +++ b/packages/vue3/test-app/Pages/Layers/Waypoint.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/vue3/test-app/Pages/SSR/Layer.vue b/packages/vue3/test-app/Pages/SSR/Layer.vue new file mode 100644 index 000000000..5d6315b9c --- /dev/null +++ b/packages/vue3/test-app/Pages/SSR/Layer.vue @@ -0,0 +1,10 @@ + + + diff --git a/packages/vue3/test-app/Pages/SSR/LayerBase.vue b/packages/vue3/test-app/Pages/SSR/LayerBase.vue new file mode 100644 index 000000000..0648910b9 --- /dev/null +++ b/packages/vue3/test-app/Pages/SSR/LayerBase.vue @@ -0,0 +1,3 @@ + diff --git a/packages/vue3/test-app/Pages/SSR/LayerLoading.vue b/packages/vue3/test-app/Pages/SSR/LayerLoading.vue new file mode 100644 index 000000000..294bb9590 --- /dev/null +++ b/packages/vue3/test-app/Pages/SSR/LayerLoading.vue @@ -0,0 +1,3 @@ + diff --git a/packages/vue3/test-app/Pages/TypeScriptLayoutProps.vue b/packages/vue3/test-app/Pages/TypeScriptLayoutProps.vue index 968a992df..66bde1433 100644 --- a/packages/vue3/test-app/Pages/TypeScriptLayoutProps.vue +++ b/packages/vue3/test-app/Pages/TypeScriptLayoutProps.vue @@ -7,6 +7,8 @@ declare module '@inertiajs/core' { layoutProps: { title: string showSidebar: boolean + baseChrome?: string + layerChrome?: string } namedLayoutProps: { app: { title: string; theme: 'light' | 'dark' } diff --git a/packages/vue3/test-app/app.ts b/packages/vue3/test-app/app.ts index e0f92bf0f..f2fe0a4c8 100644 --- a/packages/vue3/test-app/app.ts +++ b/packages/vue3/test-app/app.ts @@ -1,12 +1,16 @@ import type { HttpClient, HttpClientOptions, Page } from '@inertiajs/core' import { axiosAdapter, type VisitOptions } from '@inertiajs/core' -import { createInertiaApp, router } from '@inertiajs/vue3' +import { createInertiaApp, router, setLayoutProps } from '@inertiajs/vue3' import type { DefineComponent } from 'vue' -import { createApp, h } from 'vue' +import { createApp, createSSRApp, h } from 'vue' +import AdminLayout from './Layouts/AdminLayout.vue' import AppLayout from './Layouts/AppLayout.vue' import DefaultLayout from './Layouts/DefaultLayout.vue' +import Layer from './Layouts/Layer.vue' +import LoadingBase from './Pages/Layers/LoadingBase.vue' +import LayerLoading from './Pages/SSR/LayerLoading.vue' -window.testing = { Inertia: router } +window.testing = { Inertia: router, setLayoutProps } window.resolverReceivedPage = null as Page | null const params = new URLSearchParams(window.location.search) @@ -40,10 +44,31 @@ createInertiaApp({ await new Promise((resolve) => setTimeout(resolve, 50)) } + if (name === 'Layers/Slow') { + // Long enough for a test to supersede the layer visit while it is still resolving + await new Promise((resolve) => setTimeout(resolve, 500)) + } + + if (name === 'Layers/SlowImport') { + // Long enough for a dismissal to supersede a walk landing while its component resolves + await new Promise((resolve) => setTimeout(resolve, 700)) + } + return pages[`./Pages/${name}.vue`] }, + layer: Layer, + loading: (url: string, page: Page) => { + window.loadingResolved = [url, ...(page.layers ?? []).map((layer) => layer.key)].join('|') + + if (url.startsWith('/ssr/layer-loading-base')) { + return LayerLoading + } + + return url.startsWith('/layers/loading') ? LoadingBase : undefined + }, setup({ el, App, props, plugin }) { - const inst = createApp({ render: () => h(App, props) }) + const root = { render: () => h(App, props) } + const inst = el.hasAttribute('data-server-rendered') ? createSSRApp(root) : createApp(root) if (!window.location.pathname.startsWith('/plugin/without')) { inst.use(plugin) @@ -62,6 +87,9 @@ createInertiaApp({ ...(params.has('withDefaultLayout') && { layout: () => DefaultLayout, }), + ...(params.has('withUrlBasedLayout') && { + layout: (name: string, page) => (page.url?.startsWith('/layers/base') ? AdminLayout : DefaultLayout), + }), ...(params.has('withDefaultAppLayout') && { layout: () => AppLayout, }), @@ -91,4 +119,7 @@ createInertiaApp({ ...(params.has('withTitleCallback') && { title: (title, page) => [title, page.props.titleSuffix].filter(Boolean).join(' | '), }), + ...(params.has('withLayersTitleCallback') && { + title: (title, page) => [title, page.props.suffix].filter(Boolean).join(' | '), + }), }) diff --git a/packages/vue3/test-app/ssr-auto.ts b/packages/vue3/test-app/ssr-auto.ts index 251027b4f..55888b877 100644 --- a/packages/vue3/test-app/ssr-auto.ts +++ b/packages/vue3/test-app/ssr-auto.ts @@ -1,5 +1,6 @@ import { createInertiaApp } from '@inertiajs/vue3' import type { DefineComponent } from 'vue' +import Layer from './Layouts/Layer.vue' // This file uses createInertiaApp as a standalone expression (not exported) // The Vite plugin SSR transform will automatically: @@ -8,6 +9,7 @@ import type { DefineComponent } from 'vue' // 3. Export a default render function createInertiaApp<{ locale?: string }>({ + layer: Layer, resolve: (name) => { const pages = import.meta.glob('./Pages/SSR/**/*.vue', { eager: true }) return pages[`./Pages/${name}.vue`] diff --git a/packages/vue3/test-app/ssr.ts b/packages/vue3/test-app/ssr.ts index 12d683038..2170ec4bf 100644 --- a/packages/vue3/test-app/ssr.ts +++ b/packages/vue3/test-app/ssr.ts @@ -2,11 +2,21 @@ import { createInertiaApp } from '@inertiajs/vue3' import createServer from '@inertiajs/vue3/server' import { createSSRApp, h, type DefineComponent } from 'vue' import { renderToString } from 'vue/server-renderer' +import Layer from './Layouts/Layer.vue' createServer((page) => createInertiaApp({ page, render: renderToString, + layer: Layer, + loading: (url: string) => { + if (!url.startsWith('/ssr/layer-loading-base')) { + return undefined + } + + const pages = import.meta.glob('./Pages/SSR/**/*.vue', { eager: true }) + return pages['./Pages/SSR/LayerLoading.vue'] + }, serverHead: (page) => page.props.head as string[], resolve: (name) => { const pages = import.meta.glob('./Pages/SSR/**/*.vue', { eager: true }) diff --git a/packages/vue3/test-app/types.d.ts b/packages/vue3/test-app/types.d.ts index 04cb11a58..a037abe85 100644 --- a/packages/vue3/test-app/types.d.ts +++ b/packages/vue3/test-app/types.d.ts @@ -1,9 +1,13 @@ -import type { Method, Page, PageProps, Router } from '@inertiajs/core' +import type { LayerHandle, Method, Page, PageProps, Router } from '@inertiajs/core' +import { setLayoutProps as adapterSetLayoutProps } from '@inertiajs/vue3' declare global { interface Window { testing: { Inertia: Router + setLayoutProps?: typeof adapterSetLayoutProps + childHandle?: LayerHandle + layerChildHandle?: LayerHandle } initialPage: Page _inertia_request_dump: { @@ -26,6 +30,7 @@ declare global { _inertia_content_layout_id: string | undefined _plugin_global_props: object resolverReceivedPage: Page | null + loadingResolved: string | undefined } interface ImportMeta { diff --git a/tests/app/server.js b/tests/app/server.js index 61725b910..8f1a2217c 100644 --- a/tests/app/server.js +++ b/tests/app/server.js @@ -134,6 +134,55 @@ app.get('/ssr/server-head', (req, res) => }), ) +app.get('/ssr/layer', (req, res) => + inertia.renderSSR(req, res, { + component: 'SSR/Layer', + props: {}, + layer: { key: 'ssr-layer', base: '/ssr/layer-base' }, + }), +) + +// A cold render resolves props.head from the layer, even though it draws over a blank base. +app.get('/ssr/layer-head', (req, res) => + inertia.renderSSR(req, res, { + component: 'SSR/Layer', + props: { + head: ['Layer Head SSR', ''], + }, + layer: { key: 'ssr-layer-head', base: '/ssr/layer-base' }, + }), +) + +app.get('/ssr/layer-base', (req, res) => + inertia.renderSSR(req, res, { + component: 'SSR/LayerBase', + props: {}, + }), +) + +// The base beneath this is what the SSR entries' `loading` option matches on. +app.get('/ssr/layer-loading', (req, res) => + inertia.renderSSR(req, res, { + component: 'SSR/Layer', + props: {}, + layer: { key: 'ssr-layer-loading', base: '/ssr/layer-loading-base?delay=1500' }, + }), +) + +app.get('/ssr/layer-loading-base', (req, res) => { + const respond = () => + inertia.renderSSR(req, res, { + component: 'SSR/LayerBase', + props: {}, + }) + + if (req.query.delay) { + setTimeout(respond, Number(req.query.delay)) + } else { + respond() + } +}) + // SSR auto-transform test routes (uses the Vite plugin SSR transform) app.get('/ssr-auto/page1', (req, res) => inertia.renderSSRAuto(req, res, { @@ -168,6 +217,14 @@ app.get('/ssr-auto/async', (req, res) => }), ) +app.get('/ssr-auto/layer', (req, res) => + inertia.renderSSRAuto(req, res, { + component: 'SSR/Layer', + props: {}, + layer: { key: 'ssr-layer', base: '/ssr/layer-base' }, + }), +) + app.get('/ssr/infinite-scroll', (req, res) => { const { paginated, scrollProp } = paginateUsers(1, 15, 40) @@ -1095,6 +1152,13 @@ app.get('/prefetch/tags/:pageNumber{/:propType}', (req, res) => { }) }) +app.get('/history/non-serializable', (req, res) => { + inertia.render(req, res, { + component: 'History/NonSerializable', + props: {}, + }) +}) + app.get('/history/:pageNumber', (req, res) => { inertia.render(req, res, { component: 'History/Page', @@ -1132,6 +1196,458 @@ app.get('/history-quota/:pageNumber', (req, res) => { }) }) +// A counter per browser context, not per server: Playwright gives each test its own cookie jar, so +// a row can assert an absolute count without depending on the rows that ran before it. +const countFor = (req, res, name) => { + const seen = Number((req.headers.cookie || '').match(new RegExp(`${name}=(\\d+)`))?.[1] ?? 0) + + res.cookie(name, String(seen + 1), { path: '/' }) + + return seen +} + +app.get('/layers/base', (req, res) => { + // `?stale` leaves behind what a failed form post would, so a layer opening over it has something + // to wrongly replay. `?delay` holds the response open long enough to close a layer mid-walk. The + // settled users scroll prop is what a layer's own infinite scroll must not read instead of its own. + const stale = req.query.stale !== undefined + const respond = () => + inertia.render(req, res, { + component: 'Layers/Base', + props: { + likes: 0, + ...(stale && { errors: { name: 'The name field is required.' } }), + }, + flash: stale ? { message: 'Base flash' } : undefined, + scrollProps: { + users: { pageName: 'page', currentPage: 1, nextPage: null, previousPage: null, reset: false }, + }, + }) + + if (req.query.delay) { + setTimeout(respond, Number(req.query.delay)) + } else { + respond() + } +}) + +app.post('/layers/like', (req, res) => { + // Slow enough that a test can assert on the optimistic state while the request is still in flight + setTimeout(() => inertia.render(req, res, { component: 'Layers/Base', props: { likes: 1 } }), 1000) +}) + +// Counts its answers, so a layer-targeted partial has something observable to refresh. +app.get('/layers/panel/:name', (req, res) => { + // `?invalid` is what a failed submit redirects back to; `?delay` holds the response open. + const respond = () => + inertia.render(req, res, { + component: 'Layers/Panel', + props: { + name: req.params.name, + count: countFor(req, res, 'layers-panel-answers') + 1, + ...(req.query.invalid !== undefined && { errors: { note: 'The note field is required.' } }), + }, + layer: { key: `panel:${req.params.name}`, base: '/layers/base' }, + }) + + if (req.query.delay) { + setTimeout(respond, Number(req.query.delay)) + } else { + respond() + } +}) + +// A layer with a deferred group of its own. The follow-up is held open long enough for a test to +// see the fallback before it resolves. +app.get('/layers/deferred', (req, res) => { + const partial = req.headers['x-inertia-partial-data'] !== undefined + const respond = () => + inertia.render(req, res, { + component: 'Layers/Deferred', + props: { + name: 'deferred', + ...(partial && { stats: [1, 2, 3] }), + }, + layer: { key: 'deferred-panel', base: '/layers/base' }, + ...(!partial && { deferredProps: { slow: ['stats'] }, rescuedProps: ['stats'] }), + }) + + if (partial) { + setTimeout(respond, 300) + } else { + respond() + } +}) + +app.post('/layers/panel/:name/save', (req, res) => res.redirect(303, `/layers/panel/${req.params.name}?invalid`)) + +// The same failed save, held open long enough for the optimistic update to be observable. +app.post('/layers/panel/:name/save-slow', (req, res) => + res.redirect(303, `/layers/panel/${req.params.name}?invalid&delay=500`), +) + +app.post('/layers/panel/:name/next', (req, res) => res.redirect(303, '/layers/panel/second')) + +// One key, two urls: the second step rewrites the layer the first opened rather than stacking. +app.get('/layers/step/:name', (req, res) => { + inertia.render(req, res, { + component: 'Layers/Panel', + props: { + name: req.params.name, + }, + layer: { key: 'step', base: '/layers/base' }, + }) +}) + +app.get('/layers/slow', (req, res) => { + inertia.render(req, res, { + component: 'Layers/Slow', + props: {}, + layer: { key: 'slow', base: '/layers/base' }, + }) +}) + +app.get('/layers/waypoint/:name', (req, res) => { + inertia.render(req, res, { + component: 'Layers/Waypoint', + props: { + name: req.params.name, + next: req.params.name === 'a' ? 'b' : 'c', + }, + }) +}) + +const layersSignedIn = (req) => /layers-auth=1/.test(req.headers.cookie || '') + +const layersIntended = (req) => (req.headers.cookie || '').match(/layers-intended=([^;]+)/) + +app.get('/layers/settings', (req, res) => { + if (!layersSignedIn(req)) { + // The session expired mid-click, so the layer the user asked for is remembered and signing + // back in returns them to it. + res.cookie('layers-intended', req.originalUrl, { path: '/' }) + + return res.redirect(302, '/layers/login') + } + + inertia.render(req, res, { + component: 'Layers/Settings', + props: {}, + layer: { key: 'settings', base: '/layers/base' }, + }) +}) + +app.get('/layers/login', (req, res) => inertia.render(req, res, { component: 'Layers/Login', props: {} })) + +// The detour fixtures: the guarded route redirects to a marked prompt until the complete POST +// confirms it, so the open lands on the prompt and the return trip re-composes over the base. +// `?mark` renders the layer response carrying the mark too, which is the malformed case. +const layersGuardedConfirmed = (req) => /layers-confirmed=1/.test(req.headers.cookie || '') + +app.get('/layers/guarded', (req, res) => { + if (!layersGuardedConfirmed(req)) { + return res.redirect(302, '/layers/prompt') + } + + inertia.render(req, res, { + component: 'Layers/Guarded', + // A prop the layer component does not declare, so a fallthrough attribute would show up on it. + props: { note: 'guarded' }, + layer: { key: 'guarded', base: '/layers/base' }, + ...(req.query.mark ? { interstitial: true } : {}), + }) +}) + +app.get('/layers/prompt', (req, res) => + inertia.render(req, res, { component: 'Layers/Prompt', props: {}, interstitial: true }), +) + +app.post('/layers/prompt/complete', (req, res) => { + res.cookie('layers-confirmed', '1', { path: '/' }) + + res.redirect(303, '/layers/guarded') +}) + +// The layer opens cold over the headed base, so the walk brings the base's in after the +// layer's own and the head rows can watch which title survives. +app.get('/layers/headed', (req, res) => + inertia.render(req, res, { + component: 'Layers/Headed', + props: { suffix: 'Layered' }, + layer: { key: 'headed', base: '/layers/headed-base' }, + }), +) + +app.get('/layers/headed-base', (req, res) => inertia.render(req, res, { component: 'Layers/HeadedBase', props: {} })) + +// Each page carries its own [scroll-region], and the layer opens cold over the base, so a row can +// scroll the layer's during the cold window and watch the walk restore each tier its own. +app.get('/layers/scroll-base', (req, res) => inertia.render(req, res, { component: 'Layers/ScrollBase', props: {} })) + +app.get('/layers/scroll', (req, res) => + inertia.render(req, res, { + component: 'Layers/ScrollLayer', + props: {}, + layer: { key: 'scroll', base: '/layers/scroll-base' }, + }), +) + +// The layer's own infinite scroll, merged into the open layer on the carried-layerId reload. The +// base's settled users scroll prop sits untouched on the composite. +app.get('/layers/infinite', (req, res) => { + const page = req.query.page ? parseInt(req.query.page) : 1 + const { paginated, scrollProp } = paginateUsers(page, 15, 40) + + inertia.render(req, res, { + component: 'Layers/Infinite', + props: { users: paginated }, + mergeProps: ['users.data'], + scrollProps: { users: scrollProp }, + layer: { key: 'infinite', base: '/layers/base' }, + }) +}) + +app.post('/layers/login', (req, res) => { + const intended = layersIntended(req) + + res.cookie('layers-auth', '1', { path: '/' }) + + res.redirect(303, intended ? decodeURIComponent(intended[1]) : '/layers/base') +}) + +// A chain two deep: opening the outer url cold walks through both, in three requests. +app.get('/layers/chain/outer', (req, res) => + inertia.render(req, res, { + component: 'Layers/ChainOuter', + props: {}, + layer: { key: 'chain-outer', base: '/layers/chain/inner' }, + }), +) + +app.get('/layers/chain/inner', (req, res) => + inertia.render(req, res, { + component: 'Layers/ChainInner', + props: {}, + layer: { key: 'chain-inner', base: '/layers/base' }, + }), +) + +// A chain three deep: the walk inserts the middle and bottom layers beneath dialogs already open. +app.get('/layers/chain/top', (req, res) => + inertia.render(req, res, { + component: 'Layers/ChainOuter', + props: {}, + layer: { key: 'chain-top', base: '/layers/chain/middle' }, + }), +) + +app.get('/layers/chain/middle', (req, res) => + inertia.render(req, res, { + component: 'Layers/ChainInner', + props: {}, + layer: { key: 'chain-middle', base: '/layers/chain/inner' }, + }), +) + +// The base answers the walk with a server error, so the walk lands on the layer it reached deepest +// instead of painting the error overlay over it. +app.get('/layers/cold-fail', (req, res) => + inertia.render(req, res, { + component: 'Layers/Panel', + props: { name: 'fail' }, + layer: { key: 'fail', base: '/layers/fail-hop' }, + }), +) + +app.get('/layers/fail-hop', (req, res) => { + res.header('Vary', 'Accept') + res.header('X-Inertia', true) + return res.status(500).json({ + component: 'ErrorPage', + props: { status: 500 }, + url: req.originalUrl, + version: null, + }) +}) + +// The walk's first hop 500s; the recovery visit the landing makes is answered with the base page. +app.get('/layers/fail-once', (req, res) => { + if (countFor(req, res, 'layers-fail-once-answered') === 0) { + res.header('Vary', 'Accept') + res.header('X-Inertia', true) + return res.status(500).json({ + component: 'ErrorPage', + props: { status: 500 }, + url: req.originalUrl, + version: null, + }) + } + + return inertia.render(req, res, { + component: 'Layers/Base', + props: { likes: 0 }, + }) +}) + +app.get('/layers/fail-recover', (req, res) => + inertia.render(req, res, { + component: 'Layers/Panel', + props: { name: 'fail' }, + layer: { key: 'fail-recover', base: '/layers/fail-once' }, + }), +) + +// The declared base is held open long enough to close the layer while its walk is still out. +app.get('/layers/slow-base-panel', (req, res) => + inertia.render(req, res, { + component: 'Layers/Panel', + props: { name: 'slow' }, + layer: { key: 'slow-base-panel', base: '/layers/base?delay=1500' }, + }), +) + +// `likes` counts every answer, so a reload, or a close's refresh, is visibly not the same page. +app.get('/layers/counted/base', (req, res) => + inertia.render(req, res, { + component: 'Layers/Base', + props: { likes: countFor(req, res, 'layers-counted-base-answers') }, + }), +) + +// Counts its answers too, and drops a prop it rendered on the open whenever the request carries +// partial headers, so the layer merge keeps the dropped prop as the deliberate ghost. +app.get('/layers/counted/panel', (req, res) => + inertia.render(req, res, { + component: 'Layers/Panel', + props: { + name: 'counted', + count: countFor(req, res, 'layers-counted-panel-answers') + 1, + ...(!req.headers['x-inertia-partial-data'] && { tag: 'first' }), + }, + layer: { key: 'counted-panel', base: '/layers/counted/base' }, + }), +) + +// A layer the base opens through a handle, which the base's handle hears events from. +app.get('/layers/child', (req, res) => + inertia.render(req, res, { + component: 'Layers/Child', + props: {}, + layer: { key: 'child', base: '/layers/base' }, + }), +) + +// Stages a flash in a cookie, standing in for the session flash Laravel keeps for the refresh the +// client runs after a close. The route below emits it exactly once. +app.post('/layers/close', (req, res) => { + res.cookie('layers-close-flash', 'Saved from the panel', { path: '/' }) + + inertia.render(req, res, { close: true }) +}) + +app.get('/layers/close-flash/base', (req, res) => { + const staged = (req.headers.cookie || '').match(/layers-close-flash=([^;]+)/) + + if (staged) { + res.clearCookie('layers-close-flash', { path: '/' }) + } + + inertia.render(req, res, { + component: 'Layers/Base', + props: { likes: countFor(req, res, 'layers-close-base-answers') }, + ...(staged && { flash: { message: decodeURIComponent(staged[1]) } }), + }) +}) + +// A layer with no base is not a layer at all cold: its url renders as the page it would be. +app.get('/layers/promote-cold', (req, res) => + inertia.render(req, res, { + component: 'Layers/Panel', + props: { name: 'promote' }, + layer: { key: 'promote' }, + }), +) + +// The bases beneath these are what the test apps' `loading` option matches on. +app.get('/layers/loading/base', (req, res) => { + const respond = () => inertia.render(req, res, { component: 'Layers/Base', props: { likes: 0 } }) + + if (req.query.delay) { + setTimeout(respond, Number(req.query.delay)) + } else { + respond() + } +}) + +app.get('/layers/loading/panel', (req, res) => + inertia.render(req, res, { + component: 'Layers/Panel', + props: { name: 'loading' }, + layer: { key: 'loading-panel', base: '/layers/loading/base?delay=1500' }, + }), +) + +app.get('/layers/loading/slow-import', (req, res) => + inertia.render(req, res, { component: 'Layers/SlowImport', props: {} }), +) + +app.get('/layers/loading/slow-import/panel', (req, res) => + inertia.render(req, res, { + component: 'Layers/Panel', + props: { name: 'slow-import' }, + layer: { key: 'loading-slow-import', base: '/layers/loading/slow-import' }, + }), +) + +app.get('/layers/loading/chain/outer', (req, res) => + inertia.render(req, res, { + component: 'Layers/ChainOuter', + props: {}, + layer: { key: 'loading-chain-outer', base: '/layers/loading/chain/inner' }, + }), +) + +app.get('/layers/loading/chain/inner', (req, res) => + inertia.render(req, res, { + component: 'Layers/ChainInner', + props: {}, + layer: { key: 'loading-chain-inner', base: '/layers/loading/base?delay=1500' }, + }), +) + +// A page whose module scope calls usePage(), which must not throw outside component init. +app.get('/layers/module-scope', (req, res) => + inertia.render(req, res, { + component: 'Layers/ModuleScope', + props: {}, + }), +) + +// A hop that does not carry the version is one whose layer response never seeded the walk. +app.get('/layers/version/outer', (req, res) => + inertia.render(req, res, { + component: 'Layers/Panel', + props: { name: 'version' }, + layer: { key: 'version', base: '/layers/version-guard' }, + version: 'v1', + }), +) + +app.get('/layers/version-guard', (req, res) => { + if (req.get('X-Inertia-Version') === 'v1') { + return inertia.render(req, res, { + component: 'Layers/Base', + props: { likes: 0 }, + }) + } + + // The version mismatch Laravel's middleware answers with: the hop is a background visit, so the + // client lands the walk rather than reloading, and the base the layer declared never arrives. + res.header('X-Inertia-Location', req.originalUrl) + res.header('X-Inertia-Version', 'v1') + return res.status(409).send('') +}) + app.get('/when-visible', (req, res) => { const page = () => inertia.render(req, res, { diff --git a/tests/history.spec.ts b/tests/history.spec.ts index 7ef60e783..baa5ca081 100644 --- a/tests/history.spec.ts +++ b/tests/history.spec.ts @@ -174,6 +174,26 @@ test('it handles bfcache restoration after history is cleared', async ({ page }) expect(requests.requests.length).toBeGreaterThan(0) }) +test('a non-serializable prop does not break the visit or block the history queue', async ({ page }) => { + await page.goto('/history/non-serializable') + + await page.getByRole('button', { name: 'Push a function prop' }).click() + + await expect(page).toHaveURL('/history/non-serializable/target') + await expect(page.getByText('Target page')).toBeVisible() + + // The function is dropped from history state, the rest of the props are kept + const historyState = await page.evaluate(() => window.history.state) + await expect(historyState.page.props.message).toBe('Target page') + await expect(historyState.page.props.onAction).toBeUndefined() + + // The client visit queue must still process visits after the function prop was dropped + await page.getByRole('button', { name: 'Client visit' }).click() + + await expect(page).toHaveURL('/history/non-serializable/landed') + await expect(page.getByText('Client visit landed')).toBeVisible() +}) + test('will pull from server if history version is different than current version when pressing back', async ({ page, }) => { diff --git a/tests/layers.spec.ts b/tests/layers.spec.ts new file mode 100644 index 000000000..6713963e1 --- /dev/null +++ b/tests/layers.spec.ts @@ -0,0 +1,2209 @@ +import { expect, test } from '@playwright/test' +import { clickAndWaitForResponse, requests, scrollElementTo } from './support' + +declare const process: { env: { PACKAGE?: string } } + +test('a layer response renders over the page already displayed', async ({ page }) => { + await page.goto('/layers/base') + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.locator('#layer-count')).toHaveText('0') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + + await expect(page.getByText('Panel layer: first')).toBeVisible() + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.locator('#layer-count')).toHaveText('1') +}) + +test("usePage() inside a layer reads the layer's own page, not the page beneath it", async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + + await expect(page.locator('[data-layer-index="0"] [data-testid="layer-use-page"]')).toHaveText( + 'first|/layers/panel/first|Layers/Panel', + ) +}) + +test('usePage() at module scope falls back to the global page without throwing', async ({ page }) => { + // Svelte 5's getContext throws outside component init, so the accessor guards the read. The + // module block runs before the app mounts, so the store it falls back to is still empty. + test.skip(process.env.PACKAGE !== 'svelte', 'Svelte-only: module-scope usePage guard') + + await page.goto('/layers/module-scope') + + await expect(page.getByText('Module scope page')).toBeVisible() + await expect(page.locator('[data-testid="module-scope-page"]')).toHaveText('Layers/ModuleScope|/layers/module-scope') + await expect(page.evaluate(() => window.moduleScopePageUrl)).resolves.toBe('') +}) + +test('usePage() at module scope on vue3 falls back without a console warning', async ({ page }) => { + // Vue's inject() warns outside a setup() context, so the accessor guards the read with + // hasInjectionContext(). The module block runs before the app mounts, so the ref is still empty. + test.skip(process.env.PACKAGE !== 'vue3', 'Vue-only: module-scope usePage inject guard') + + const warnings: string[] = [] + page.on('console', (msg) => { + if (msg.type() === 'warning' || msg.type() === 'error') warnings.push(msg.text()) + }) + + await page.goto('/layers/module-scope') + + await expect(page.getByText('Module scope page')).toBeVisible() + await expect(page.locator('[data-testid="module-scope-page"]')).toHaveText('Layers/ModuleScope|/layers/module-scope') + await expect(page.evaluate(() => window.moduleScopePageUrl)).resolves.toBeUndefined() + expect(warnings.filter((w) => w.includes('inject() can only be used inside setup'))).toEqual([]) +}) + +test('the page beneath a layer keeps its state', async ({ page }) => { + await page.goto('/layers/base') + await page.locator('#note').fill('typed before the layer opened') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.getByText('Panel layer: first')).toBeVisible() + + await expect(page.locator('#note')).toHaveValue('typed before the layer opened') +}) + +test('layers stack, and two of the same component stay separate instances', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await page.locator('[data-layer-index="0"] .panel-note').fill('typed in the first panel') + + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Open next panel' }).click() + + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.getByText('Panel layer: first')).toBeVisible() + await expect(page.getByText('Panel layer: second')).toBeVisible() + + await expect(page.locator('[data-layer-index="0"]')).toHaveAttribute('data-layer-top', 'false') + await expect(page.locator('[data-layer-index="1"]')).toHaveAttribute('data-layer-top', 'true') + + await expect(page.locator('[data-layer-index="0"] .panel-note')).toHaveValue('typed in the first panel') + await expect(page.locator('[data-layer-index="1"] .panel-note')).toHaveValue('') +}) + +test('a layer opening does not replay the flash and errors of the page beneath', async ({ page }) => { + await page.goto('/layers/base?stale') + await expect(page.locator('#page-error')).toHaveText('The name field is required.') + + await clickAndWaitForResponse(page, 'Open panel with callbacks', '/layers/panel/first', 'button') + await expect(page.getByText('Panel layer: first')).toBeVisible() + + await expect(page.locator('#visit-events')).toHaveText('success') + await expect(page.locator('#page-error')).toHaveText('The name field is required.') +}) + +test('a form submitted inside a layer lands its errors on the layer, not the page beneath', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.getByText('Panel layer: first')).toBeVisible() + + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Submit panel form', exact: true }).click() + + await expect(page.locator('.panel-form-error')).toHaveText('The note field is required.') + await expect(page.locator('#page-error')).toHaveCount(0) + await expect(page.getByText('Panel layer: first')).toBeVisible() +}) + +test("a form's optimistic update inside a layer applies to the layer's own props", async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + // The route counts its answers per browser context, so this is the first one this test has had. + await expect(page.locator('[data-testid="panel-count"]')).toHaveText('1') + + // 99 is a value the route never answers with, so it can only have come from the callback. + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Submit panel form optimistically' }).click() + + await expect(page.locator('[data-testid="panel-count"]')).toHaveText('99') +}) + +test('an optimistic update leaves an open stack standing', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.getByText('Panel layer: first')).toBeVisible() + + // The base is inert under the layer's dialog, so the post is issued through the router. + await page.evaluate(() => + window.testing.Inertia.optimistic((props: { likes: number }) => ({ likes: props.likes + 1 })).post('/layers/like'), + ) + + await expect(page.locator('#likes')).toHaveText('1') + await expect(page.getByText('Panel layer: first')).toBeVisible() +}) + +test('a client visit that installs another page leaves an open stack behind', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.locator('#layer-count')).toHaveText('1') + + // The base is inert under the layer's dialog, so the visit is issued through the router. + await page.evaluate(() => + window.testing.Inertia.push({ + url: '/layers/panel/client', + component: 'Layers/Panel', + props: { name: 'from a client visit' }, + }), + ) + + await expect(page.getByText('Panel layer: from a client visit')).toBeVisible() + await expect(page.getByText('Base page')).toBeHidden() + await expect(page.getByText('Panel layer: first')).toBeHidden() + await expect(page.locator('[data-layer-index]')).toHaveCount(0) +}) + +test('opening a layer leaves the page beneath scrolled where the user left it', async ({ page }) => { + await page.goto('/layers/base') + + // The spacer sits above the content, so scrolling to the bottom leaves the link where it is. + await scrollElementTo( + page, + page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)), + ) + const scrolled = await page.evaluate(() => window.scrollY) + expect(scrolled).toBeGreaterThan(0) + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.getByText('Panel layer: first')).toBeVisible() + + expect(await page.evaluate(() => window.scrollY)).toBe(scrolled) +}) + +test('a layer open superseded before it resolves never appears on screen', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open slow panel', '/layers/slow') + await page.waitForTimeout(150) + await page.getByRole('button', { name: 'Push panel as a page' }).click() + + await expect(page.getByText('Panel layer: from a client visit')).toBeVisible() + await expect(page.getByText('Base page')).toBeHidden() + + // Outlive the resolver delay the superseded layer is still waiting on. + await page.waitForTimeout(700) + + await expect(page.getByText('Slow layer')).toBeHidden() + await expect(page.getByText('Base page')).toBeHidden() +}) + +test('a layer the visit was aimed at opens over the page it was made from', async ({ page }) => { + await page.context().addCookies([{ name: 'layers-auth', value: '1', domain: 'localhost', path: '/' }]) + + await page.goto('/layers/base') + await clickAndWaitForResponse(page, 'Open settings', '/layers/settings') + + await expect(page.getByText('Account settings')).toBeVisible() + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.locator('#layer-count')).toHaveText('1') +}) + +test('a layer returned through a login page opens cold, so the login page is never its backdrop', async ({ page }) => { + await page.goto('/layers/base') + await expect(page.getByText('Base page')).toBeVisible() + + await clickAndWaitForResponse(page, 'Open settings', '/layers/login') + await expect(page.locator('#login-page')).toBeVisible() + await expect(page.getByText('Base page')).toBeHidden() + + await clickAndWaitForResponse(page, 'Sign in', '/layers/settings', 'button') + + await expect(page.getByText('Account settings')).toBeVisible() + // The failure to watch for is the login page still standing underneath. + await expect(page.locator('#login-page')).toBeHidden() + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(1) +}) + +test('a cold layer stands on nothing at all until the walk lands the base beneath it', async ({ page }) => { + await page.goto('/layers/base?withDefaultLayout') + await expect(page.locator('#default-layout')).toHaveCount(1) + + await clickAndWaitForResponse(page, 'Open settings', '/layers/login') + await expect(page.locator('#login-page')).toBeVisible() + + // The hop is held so the cold window can be looked at rather than raced. + let landTheBase!: () => void + const held = new Promise((resolve) => (landTheBase = resolve)) + await page.route('**/layers/base', async (route) => { + await held + await route.continue() + }) + + const walkHop = page.waitForRequest('**/layers/base') + await clickAndWaitForResponse(page, 'Sign in', '/layers/settings', 'button') + await expect(page.getByText('Account settings')).toBeVisible() + await walkHop + + await expect(page.locator('#login-page')).toBeHidden() + await expect(page.getByText('Base page')).toBeHidden() + await expect(page.locator('[data-layer-index="0"] #default-layout')).toBeVisible() + await expect(page.locator('#default-layout')).toHaveCount(1) + + landTheBase() + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.locator('#default-layout')).toHaveCount(2) + await expect(page.getByText('Account settings')).toBeVisible() +}) + +test('a defaultLayout that decides by page.url never matches a layer with no url of its own', async ({ page }) => { + await page.goto('/layers/base?withUrlBasedLayout') + await expect(page.locator('#admin-layout')).toBeVisible() + + await page.getByRole('button', { name: 'Open local layer' }).click() + + await expect(page.locator('[data-layer-index="0"] #default-layout')).toBeVisible() + await expect(page.locator('[data-layer-index="0"] #admin-layout')).toHaveCount(0) +}) + +test("a layer's layout reads the layer's own props and its own layout props", async ({ page }) => { + await page.goto('/layers/base?withDefaultLayout') + await expect(page.locator('#default-layout')).toHaveCount(1) + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + const layerId = await page.evaluate(() => (window.history.state.page.layers as { id: string }[])[0].id) + await page.evaluate((id) => window.testing.setLayoutProps({ layerChrome: 'layer' }, id), layerId) + + await expect(page.locator('[data-layer-index="0"] #layout-name')).toHaveText('first') + await expect(page.locator('[data-layer-index="0"] #layer-chrome')).toHaveText('layer') + await expect(page.locator('#layout-name').first()).toHaveText('') + await expect(page.locator('#layer-chrome').first()).toHaveText('') +}) + +test('reopening the same layer keeps its layout props, which hold through the exit', async ({ page }) => { + await page.context().addCookies([{ name: 'layers-auth', value: '1', domain: 'localhost', path: '/' }]) + await page.goto('/layers/base?withDefaultLayout') + + await clickAndWaitForResponse(page, 'Open settings', '/layers/settings') + await expect(page.locator('[data-layer-index="0"] #layer-chrome')).toHaveText('layer') + + await clickAndWaitForResponse(page, 'Open settings again', '/layers/settings') + await expect(page.locator('[data-layer-index="0"] #layer-chrome')).toHaveText('layer') + + // An exit transition makes the closing window observable. + await page.addStyleTag({ content: 'dialog[data-layer-closing="true"] { opacity: 0; transition: opacity 400ms }' }) + await page.keyboard.press('Escape') + await expect(page.locator('[data-layer-index="0"]')).toHaveAttribute('data-layer-closing', 'true') + await expect(page.locator('[data-layer-index="0"] #layer-chrome')).toHaveText('layer') + await expect(page.locator('[data-layer-index]')).toHaveCount(0) +}) + +test('closing a layer drops its layout props, so a forward restore finds nothing stale', async ({ page }) => { + await page.goto('/layers/base?withDefaultLayout') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + const layerId = await page.evaluate(() => (window.history.state.page.layers as { id: string }[])[0].id) + await page.evaluate((id) => window.testing.setLayoutProps({ layerChrome: 'layer' }, id), layerId) + await expect(page.locator('[data-layer-index="0"] #layer-chrome')).toHaveText('layer') + + await page.keyboard.press('Escape') + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + + await page.goForward() + await expect(page.getByText('Panel layer: first')).toBeVisible() + await expect(page.locator('[data-layer-index="0"] #layer-chrome')).toHaveText('') +}) + +test('a client visit that rewrites the page beneath a layer still clears what it was showing', async ({ page }) => { + await page.context().addCookies([{ name: 'layers-auth', value: '1', domain: 'localhost', path: '/' }]) + await page.goto('/layers/base?withDefaultLayout') + await expect(page.locator('#default-layout')).toHaveCount(1) + + // Set by hand rather than on mount: a remount would re-apply it and hide the reset. + await page.getByRole('button', { name: 'Set base chrome' }).click() + await expect(page.locator('#base-chrome')).toHaveText('base') + + await clickAndWaitForResponse(page, 'Open settings', '/layers/settings') + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + + await page.evaluate(() => + window.testing.Inertia.replace({ url: '/layers/base?withDefaultLayout', props: { likes: 0 } }), + ) + + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page.locator('#base-chrome').first()).toHaveText('') +}) + +test('a layer opened cold keeps its title when the page beneath it arrives', async ({ page }) => { + test.skip(process.env.PACKAGE === 'svelte', 'Svelte adapter has no Head component') + + // The hop is held so the title can be looked at before and after its ancestry arrives. Firefox + // does not fire load while a route is holding a request, so the navigation waits for commit. + let landTheBase!: () => void + const held = new Promise((resolve) => (landTheBase = resolve)) + await page.route('**/layers/headed-base', async (route) => { + await held + await route.continue() + }) + + await page.goto('/layers/headed', { waitUntil: 'commit' }) + await expect(page).toHaveTitle('Headed Layer') + + landTheBase() + await expect(page.getByText('Headed base page')).toBeVisible() + await expect(page).toHaveTitle('Headed Layer') +}) + +test("a stack opened cold keeps the top layer's title as the layers beneath it arrive", async ({ page }) => { + test.skip(process.env.PACKAGE === 'svelte', 'Svelte adapter has no Head component') + + await page.goto('/layers/chain/outer') + + await expect(page).toHaveTitle('Chain outer') + await expect(page.getByText('Chain inner')).toBeVisible() + await expect(page.getByText('Base page')).toBeVisible() +}) + +test('a title callback that reads page props is applied to the layer title, not the page', async ({ page }) => { + test.skip(process.env.PACKAGE === 'svelte', 'Svelte adapter has no Head component') + + // The hop is held so the title can be looked at during the cold window. + let landTheBase!: () => void + const held = new Promise((resolve) => (landTheBase = resolve)) + await page.route('**/layers/headed-base', async (route) => { + await held + await route.continue() + }) + + await page.goto('/layers/headed?withLayersTitleCallback', { waitUntil: 'commit' }) + await expect(page).toHaveTitle('Headed Layer | Layered') + + landTheBase() + await expect(page.getByText('Headed base page')).toBeVisible() + await expect(page).toHaveTitle('Headed Layer | Layered') +}) + +test('renders a wrapper carrying the layer id, and none when no layer is open', async ({ page }) => { + await page.goto('/layers/base') + await expect(page.locator('[data-layer-id]')).toHaveCount(0) + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + const layerId = await page.evaluate(() => (window.history.state.page.layers as { id: string }[])[0].id) + + await expect(page.locator('[data-layer-id]')).toHaveCount(1) + await expect(page.locator(`[data-layer-id="${layerId}"]`)).toHaveCount(1) + await expect(page.locator(`[data-layer-id="${layerId}"]`)).toContainText('Panel layer: first') +}) + +test('a cold layer scroll region restores its own position, never the base', async ({ page }) => { + // The base hop is held so the layer's region can be scrolled while it is the only one on screen. + let landTheBase!: () => void + const held = new Promise((resolve) => (landTheBase = resolve)) + await page.route('**/layers/scroll-base', async (route) => { + await held + await route.continue() + }) + + await page.goto('/layers/scroll', { waitUntil: 'commit' }) + await expect(page.locator('#layer-region')).toBeVisible() + + await page.evaluate(() => document.querySelector('#layer-region')?.scrollTo(0, 200)) + await page.waitForTimeout(250) + + landTheBase() + await expect(page.getByText('Scroll base page')).toBeVisible() + + await expect + .poll(() => page.evaluate(() => (document.querySelector('#layer-region') as HTMLElement | null)?.scrollTop)) + .toBe(200) + await expect + .poll(() => page.evaluate(() => (document.querySelector('#base-region') as HTMLElement | null)?.scrollTop)) + .toBe(0) +}) + +test("a restore after a layer closes still restores the page's scroll regions", async ({ page }) => { + await page.goto('/layers/scroll-base') + await expect(page.locator('#base-region')).toBeVisible() + await page.evaluate(() => document.querySelector('#base-region')?.scrollTo(0, 100)) + await page.waitForTimeout(250) + + await page.evaluate(() => window.testing.Inertia.layer({ component: 'Layers/ScrollLayer' })) + await expect(page.locator('#layer-region')).toBeVisible() + await page.evaluate(() => document.querySelector('#layer-region')?.scrollTo(0, 200)) + await page.waitForTimeout(250) + await page.keyboard.press('Escape') + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + // The close's own scroll restore settles first, or it would rewind the next scroll. + await page.waitForTimeout(300) + + await page.evaluate(() => document.querySelector('#base-region')?.scrollTo(0, 150)) + await page.waitForTimeout(250) + + const scrollState = await page.evaluate(() => ({ + scrollRegions: window.history.state?.scrollRegions, + scrollRegionLayers: window.history.state?.scrollRegionLayers, + })) + expect(scrollState.scrollRegionLayers).toBeUndefined() + + // Wait for the marker before polling the scroll, so the poll never races the navigation's + // context teardown. + await page.goto('/layers/base') + await page.goBack() + await expect(page).toHaveURL('/layers/scroll-base') + await expect(page.locator('#base-region')).toBeVisible() + + await expect + .poll(() => page.evaluate(() => (document.querySelector('#base-region') as HTMLElement | null)?.scrollTop)) + .toBe(150) +}) + +test('an infinite-scroll layer paginates its own scroll props, never the base', async ({ page }) => { + await page.goto('/layers/infinite') + await expect(page.getByText('Infinite layer')).toBeVisible() + await expect(page.getByText('User 1', { exact: true })).toBeVisible() + await expect(page.getByText('User 16')).toBeHidden() + + await page.getByRole('button', { name: 'Load next items' }).click() + + await expect(page.getByText('User 16')).toBeVisible() + const baseUsersScrollProp = await page.evaluate( + () => (window.history.state.page.scrollProps as { users?: { nextPage: number | null } })?.users, + ) + expect(baseUsersScrollProp?.nextPage).toBeNull() +}) + +test('a failed submit inside a layer keeps the page it was opened from beneath it', async ({ page }) => { + await page.goto('/layers/base') + await page.locator('#note').fill('typed before the layer opened') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await page.locator('[data-layer-index="0"] .panel-note').fill('typed in the panel') + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Save panel' }).click() + + await expect(page.locator('.panel-error')).toHaveText('The note field is required.') + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.locator('#note')).toHaveValue('typed before the layer opened') + await expect(page.locator('#layer-count')).toHaveText('1') + await expect(page.locator('[data-layer-index="0"] .panel-note')).toHaveValue('typed in the panel') +}) + +test('a layer redirected to from inside the stack opens on top of that stack', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Open next panel' }).click() + + await expect(page.getByText('Panel layer: second')).toBeVisible() + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.locator('#layer-count')).toHaveText('2') + await expect(page.locator('[data-layer-index="0"]')).toContainText('Panel layer: first') + await expect(page.locator('[data-layer-index="1"]')).toContainText('Panel layer: second') +}) + +test('a prefetched layer replayed after an instant visit opens cold, not over the page the visit put up', async ({ + page, +}) => { + await page.goto('/layers/waypoint/a') + await clickAndWaitForResponse(page, 'Prefetch the panel', '/layers/panel/first', 'button') + + await clickAndWaitForResponse(page, 'Go to the next waypoint', '/layers/waypoint/b') + await clickAndWaitForResponse(page, 'Go to the next waypoint', '/layers/waypoint/c') + await expect(page.locator('#waypoint')).toHaveText('Waypoint c') + + // The instant swap fabricates a page before the prefetched response is replayed, so the base + // the visit was dispatched from is two navigations and a swap gone. + requests.listen(page) + await page.getByRole('button', { exact: true, name: 'Instantly open the panel' }).click() + await expect(page.getByText('Panel layer: first')).toBeVisible() + + expect(requests.requests.filter((request) => request.url().includes('/layers/panel/first'))).toHaveLength(0) + + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.locator('#waypoint')).toBeHidden() +}) + +test('a background reload landing mid-flight leaves the layer composing onto the base it refreshed', async ({ + page, +}) => { + await page.goto('/layers/base') + await page.locator('#note').fill('typed before the layer opened') + + const layerResponse = page.waitForResponse((response) => response.url().includes('/layers/panel/delayed')) + await page.getByRole('link', { exact: true, name: 'Open delayed panel' }).click() + + await clickAndWaitForResponse(page, 'Refresh in the background', '/layers/base', 'button') + await layerResponse + + await expect(page.getByText('Panel layer: delayed')).toBeVisible() + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.locator('#layer-count')).toHaveText('1') + await expect(page.locator('#note')).toHaveValue('typed before the layer opened') +}) + +test('opening a layer moves the address to the layer, leaving the page beneath one entry back', async ({ page }) => { + await page.goto('/layers/base') + await expect(page).toHaveURL('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.getByText('Panel layer: first')).toBeVisible() + + await expect(page).toHaveURL('/layers/panel/first') + + await page.goBack() + + await expect(page).toHaveURL('/layers/base') + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.getByText('Panel layer: first')).toBeHidden() + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.getByText('Panel layer: first')).toBeVisible() + await expect(page).toHaveURL('/layers/panel/first') +}) + +test('forward restores the layer and its address over the page it was opened from', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page).toHaveURL('/layers/panel/first') + + await page.goBack() + await expect(page).toHaveURL('/layers/base') + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + + await page.goForward() + + await expect(page).toHaveURL('/layers/panel/first') + await expect(page.getByText('Panel layer: first')).toBeVisible() + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(1) +}) + +test('the address of a stack two layers deep is the topmost layer', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page).toHaveURL('/layers/panel/first') + + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Open next panel' }).click() + await expect(page.getByText('Panel layer: second')).toBeVisible() + await expect(page).toHaveURL('/layers/panel/second') + + await page.goBack() + await expect(page).toHaveURL('/layers/panel/first') + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page.getByText('Panel layer: second')).toBeHidden() + + await page.goBack() + await expect(page).toHaveURL('/layers/base') + await expect(page.locator('[data-layer-index]')).toHaveCount(0) +}) + +test('the entry a layer pushes carries the state of the page beneath it', async ({ page }) => { + await page.goto('/layers/base') + await page.locator('#remembered-note').fill('typed before the layer opened') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.getByText('Panel layer: first')).toBeVisible() + + await page.goBack() + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page.locator('#remembered-note')).toHaveValue('typed before the layer opened') + + requests.listen(page) + await page.goForward() + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + + await expect(page.locator('#remembered-note')).toHaveValue('typed before the layer opened') + expect(requests.requests.filter((request) => request.url().includes('/layers/'))).toHaveLength(0) +}) + +test('a layer that opens without moving the address leaves it on the layer beneath', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page).toHaveURL('/layers/panel/first') + + await clickAndWaitForResponse( + page, + 'Open a second panel without moving the address', + '/layers/panel/second', + 'button', + ) + await expect(page.getByText('Panel layer: second')).toBeVisible() + await expect(page).toHaveURL('/layers/panel/first') + + // A keystroke on the base is what writes history next. The base is inert under the layer dialogs, and + // a Playwright fill on an inert element only sets the value, so the input event is dispatched + // by hand. + await page.evaluate(() => { + const input = document.querySelector('#remembered-note') as HTMLInputElement + input.value = 'typed with two layers open' + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + + await expect(page).toHaveURL('/layers/panel/first') + await expect(page.locator('#layer-count')).toHaveText('2') +}) + +test('a layer being closed stays on screen until the shell says it is done', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page).toHaveURL('/layers/panel/first') + await expect(page.locator('[data-layer-index="0"]')).toHaveAttribute('data-layer-closing', 'false') + + // An exit transition makes the closing window observable. Nothing is clicked: the shell reports + // done itself once the exit finishes. + await page.addStyleTag({ content: 'dialog[data-layer-closing="true"] { opacity: 0; transition: opacity 400ms }' }) + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index="0"]')).toHaveAttribute('data-layer-closing', 'true') + await expect(page.getByText('Panel layer: first')).toBeVisible() + await expect(page).toHaveURL('/layers/panel/first') + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page).toHaveURL('/layers/base') + await expect(page.getByText('Base page')).toBeVisible() +}) + +test('closing a layer marks the layers above it, and waits for all of them', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Open next panel' }).click() + + // The lower layer is inert under the top one's dialog, so the close is issued through the router. + // An exit transition makes the closing window observable. + await page.addStyleTag({ content: 'dialog[data-layer-closing="true"] { opacity: 0; transition: opacity 400ms }' }) + const lowerLayerId = await page.evaluate(() => (window.history.state.page.layers as { id: string }[])[0].id) + // Not returned: the close resolves once the layers are gone, and the exit is what this asserts. + await page.evaluate((id) => void window.testing.Inertia.close(id), lowerLayerId) + + await expect(page.locator('[data-layer-index="0"]')).toHaveAttribute('data-layer-closing', 'true') + await expect(page.locator('[data-layer-index="1"]')).toHaveAttribute('data-layer-closing', 'true') + await expect(page.locator('[data-layer-index]')).toHaveCount(2) + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page).toHaveURL('/layers/base') +}) + +test('closing a layer in the middle of the stack leaves the one beneath it holding its own state', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await page.locator('[data-layer-index="0"] .panel-note').fill('typed in the first panel') + + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Open next panel' }).click() + await page.locator('[data-layer-index="1"] .panel-note').fill('typed in the second panel') + + // The panel's next button always lands on the second, and the base link is inert under the + // layer dialogs, so the third is opened through the router. + const thirdPanelResponse = page.waitForResponse((response) => response.url().includes('/layers/panel/third')) + await page.evaluate(() => window.testing.Inertia.visit('/layers/panel/third')) + await thirdPanelResponse + await page.locator('[data-layer-index="2"] .panel-note').fill('typed in the third panel') + + // The middle layer is inert under the top one's dialog, so the close is issued through the router. + const middleLayerId = await page.evaluate(() => (window.history.state.page.layers as { id: string }[])[1].id) + await page.evaluate((id) => window.testing.Inertia.close(id), middleLayerId) + + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page.getByText('Panel layer: second')).toBeHidden() + await expect(page.getByText('Panel layer: third')).toBeHidden() + + await expect(page.locator('[data-layer-index="0"]')).toContainText('Panel layer: first') + await expect(page.locator('[data-layer-index="0"] .panel-note')).toHaveValue('typed in the first panel') + await expect(page).toHaveURL('/layers/panel/first') +}) + +test('the browser back dismisses the top layer, running its exit like any other close', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.locator('[data-layer-index="0"]')).toHaveAttribute('data-layer-closing', 'false') + + // An exit transition makes the closing window observable. The shell reports done once it finishes. + await page.addStyleTag({ content: 'dialog[data-layer-closing="true"] { opacity: 0; transition: opacity 400ms }' }) + await page.goBack() + + await expect(page.locator('[data-layer-index="0"]')).toHaveAttribute('data-layer-closing', 'true') + await expect(page.getByText('Panel layer: first')).toBeVisible() + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page).toHaveURL('/layers/base') + await expect(page.getByText('Base page')).toBeVisible() +}) + +test('a history jump past the entry beneath drops the stack rather than dismissing it', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Open child layer' }).click() + await expect(page.locator('#layer-count')).toHaveText('2') + + // A long exit makes a dismissal impossible to miss: the stack would still be on screen when the + // assertion below runs, rather than gone with the swap. + await page.addStyleTag({ content: 'dialog[data-layer-closing="true"] { opacity: 0; transition: opacity 2000ms }' }) + await page.evaluate(() => window.history.go(-2)) + + await expect(page.locator('[data-layer-index]')).toHaveCount(0, { timeout: 1000 }) + await expect(page).toHaveURL('/layers/base') +}) + +test('closing a layer unwinds the history it pushed, so forward opens it again', async ({ page }) => { + await page.goto('/layers/base') + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page).toHaveURL('/layers/base') + + await page.goForward() + + await expect(page).toHaveURL('/layers/panel/first') + await expect(page.getByText('Panel layer: first')).toBeVisible() + await expect(page.getByText('Base page')).toBeVisible() +}) + +test('closing a layer that never moved the address leaves the history beneath it alone', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await clickAndWaitForResponse( + page, + 'Open a second panel without moving the address', + '/layers/panel/second', + 'button', + ) + await expect(page.locator('#layer-count')).toHaveText('2') + await expect(page).toHaveURL('/layers/panel/first') + + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page.locator('[data-layer-index="0"]')).toContainText('Panel layer: first') + await expect(page).toHaveURL('/layers/panel/first') + + await page.goBack() + await expect(page).toHaveURL('/layers/base') + await expect(page.locator('[data-layer-index]')).toHaveCount(0) +}) + +test('a layer whose key is already open is rewritten where it stands rather than replaced', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open step one', '/layers/step/one') + await expect(page.getByText('Panel layer: one')).toBeVisible() + await page.locator('[data-layer-index="0"] .panel-note').fill('typed in step one') + + const element = await page.locator('[data-layer-index="0"]').elementHandle() + + const stepTwoResponse = page.waitForResponse((response) => response.url().includes('/layers/step/two')) + await page.evaluate(() => window.testing.Inertia.visit('/layers/step/two')) + await stepTwoResponse + + await expect(page.getByText('Panel layer: two')).toBeVisible() + await expect(page).toHaveURL('/layers/step/two') + await expect(page.locator('#layer-count')).toHaveText('1') + + expect(await element!.evaluate((node) => node.isConnected)).toBe(true) + await expect(page.locator('[data-layer-index="0"] .panel-note')).toHaveValue('') +}) + +test("a rewrite that preserves state keeps the layer's component instance", async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open step one', '/layers/step/one') + await page.locator('[data-layer-index="0"] .panel-note').fill('typed in step one') + + const stepTwoResponse = page.waitForResponse((response) => response.url().includes('/layers/step/two')) + await page.evaluate(() => window.testing.Inertia.visit('/layers/step/two', { preserveState: true })) + await stepTwoResponse + + await expect(page.getByText('Panel layer: two')).toBeVisible() + await expect(page.locator('[data-layer-index="0"] .panel-note')).toHaveValue('typed in step one') +}) + +test('a layer response for an earlier level closes the layers standing above it', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open step one', '/layers/step/one') + await clickAndWaitForResponse(page, 'Open child layer', '/layers/child', 'button') + await expect(page.locator('[data-layer-index]')).toHaveCount(2) + await expect(page).toHaveURL('/layers/child') + + const stepTwoResponse = page.waitForResponse((response) => response.url().includes('/layers/step/two')) + await page.evaluate(() => window.testing.Inertia.visit('/layers/step/two')) + await stepTwoResponse + + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page.getByText('Panel layer: two')).toBeVisible() + await expect(page.getByText('Child layer', { exact: true })).toBeHidden() + await expect(page).toHaveURL('/layers/step/two') + + // The entries the closed child owned are still in front of the base, so closing what is left has + // to step back over them as well as its own. + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page).toHaveURL('/layers/base') +}) + +test('closing a layer that walked through several steps unwinds every entry they pushed', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open step one', '/layers/step/one') + const stepTwoResponse = page.waitForResponse((response) => response.url().includes('/layers/step/two')) + await page.evaluate(() => window.testing.Inertia.visit('/layers/step/two')) + await stepTwoResponse + await expect(page).toHaveURL('/layers/step/two') + await expect(page.locator('#layer-count')).toHaveText('1') + + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page).toHaveURL('/layers/base') + + await page.goForward() + + await expect(page).toHaveURL('/layers/step/one') + await expect(page.getByText('Panel layer: one')).toBeVisible() +}) + +test('a close abandoned by a landing response puts an already-exited layer back on screen', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Open next panel' }).click() + await expect(page.getByText('Panel layer: second')).toBeVisible() + + // The top layer's exit outruns the bottom's, so its dialog has closed by the time the response + // lands, remakes the bottom layer, and abandons the close. + await page.addStyleTag({ + content: ` + @keyframes layer-exit { to { opacity: 0.99 } } + dialog[data-layer-closing="true"] { animation: layer-exit 3000ms; } + dialog[data-layer-index="1"][data-layer-closing="true"] { animation-duration: 100ms; } + `, + }) + + const bottomId = await page.locator('[data-layer-index="0"] [data-layer-id]').getAttribute('data-layer-id') + + await page.evaluate((id) => { + window.testing.Inertia.visit('/layers/panel/first?delay=600', { + only: ['count'], + preserveState: false, + async: true, + }) + window.testing.Inertia.close(id!) + }, bottomId) + + await expect(page.locator('[data-layer-index="0"] [data-testid="panel-count"]')).toHaveText('3') + await expect(page.getByText('Panel layer: second')).toBeVisible() + await expect(page.getByText('Panel layer: first')).toBeVisible() + await expect(page.locator('#layer-count')).toHaveText('2') +}) + +// The cold rows below hard-load a layer's url. Everything above opened warm, over a base on screen. + +test('a layer url opened cold renders the layer and the base the walk fetches beneath it', async ({ page }) => { + await page.goto('/layers/panel/first') + + await expect(page).toHaveURL('/layers/panel/first') + + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.getByText('Panel layer: first')).toBeVisible() + await expect(page.locator('#layer-count')).toHaveText('1') + await expect(page).toHaveURL('/layers/panel/first') +}) + +test('a two-deep cold open resolves top down and keeps the address on the top layer', async ({ page }) => { + requests.listen(page) + await page.goto('/layers/chain/outer') + + await expect(page).toHaveURL('/layers/chain/outer') + + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.getByText('Chain inner')).toBeVisible() + await expect(page.getByText('Chain outer')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(2) + await expect(page.locator('[data-layer-index="0"]')).toContainText('Chain inner') + await expect(page.locator('[data-layer-index="1"]')).toContainText('Chain outer') + await expect(page.locator('[data-layer-index="1"]')).toHaveAttribute('data-layer-top', 'true') + await expect(page).toHaveURL('/layers/chain/outer') + + const layerRequests = requests.requests.filter((request) => request.url().includes('/layers/')) + expect(layerRequests.map((request) => new URL(request.url()).pathname)).toEqual([ + '/layers/chain/outer', + '/layers/chain/inner', + '/layers/base', + ]) +}) + +test('a cold chain closes one layer at a time, each close leaving the rest where it is', async ({ page }) => { + await page.goto('/layers/chain/outer') + + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.getByText('Chain inner')).toBeVisible() + await expect(page.getByText('Chain outer')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(2) + + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page.locator('[data-layer-index="0"]')).toContainText('Chain inner') + await expect(page).toHaveURL('/layers/chain/inner') + await expect(page.getByText('Chain outer')).toBeHidden() + + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.getByText('Chain inner')).toBeHidden() + await expect(page).toHaveURL('/layers/base') +}) + +test('a three-deep cold chain stacks its dialogs in layer order, so Escape closes only the top', async ({ page }) => { + await page.goto('/layers/chain/top') + + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(3) + + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(2) + await expect(page).toHaveURL('/layers/chain/middle') + + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page).toHaveURL('/layers/chain/inner') + + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page).toHaveURL('/layers/base') +}) + +test('closing a cold layer pushes the base beneath it as a step of its own', async ({ page }) => { + // The entry beneath the cold open stands for wherever the user was before the shared link. + await page.goto('/') + await page.goto('/layers/panel/first') + + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.locator('#layer-count')).toHaveText('1') + + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page).toHaveURL('/layers/base') + await expect(page.getByText('Base page')).toBeVisible() + + await page.goBack() + await expect(page).toHaveURL('/layers/panel/first') + await expect(page.getByText('Panel layer: first')).toBeVisible() + await expect(page.getByText('Base page')).toBeVisible() + + await page.goBack() + await expect(page).toHaveURL('/') + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page.getByText('Panel layer: first')).toBeHidden() +}) + +test('the hash a cold layer url arrived with survives the walk and a close', async ({ page }) => { + await page.goto('/layers/panel/first#something') + + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.getByText('Panel layer: first')).toBeVisible() + await expect(page).toHaveURL('/layers/panel/first#something') + + expect(await page.evaluate(() => window.history.state.page.layers[0].url)).toContain('#something') + + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page).toHaveURL('/layers/base') + + await page.goBack() + await expect(page.getByText('Panel layer: first')).toBeVisible() + await expect(page).toHaveURL('/layers/panel/first#something') +}) + +test('a server error fetching the base leaves the layer on screen, without the error overlay', async ({ page }) => { + await page.goto('/layers/cold-fail') + + // The overlay must never paint over the very layer whose base failed to load. + await expect(page.getByText('Panel layer: fail')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page.locator('dialog#inertia-error-dialog')).toHaveCount(0) + await expect(page).toHaveURL('/layers/cold-fail') +}) + +test('a cold layer closed while its base fails re-requests the base and replaces the blank', async ({ page }) => { + // The entry beneath the cold open stands for wherever the user was before the shared link. + await page.goto('/') + + // The hop is held so the layer can be closed while it is still out. + let releaseTheHop!: () => void + const held = new Promise((resolve) => (releaseTheHop = resolve)) + let hopRequests = 0 + await page.route('**/layers/fail-once', async (route) => { + if (hopRequests++ === 0) { + await held + } + await route.continue() + }) + + await page.goto('/layers/fail-recover', { waitUntil: 'commit' }) + + await expect(page.getByText('Panel layer: fail')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page.getByText('Base page')).toBeHidden() + await expect(page).toHaveURL('/layers/fail-once') + const entriesAfterClose = await page.evaluate(() => window.history.length) + + releaseTheHop() + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page).toHaveURL('/layers/fail-once') + expect(await page.evaluate(() => window.history.length)).toBe(entriesAfterClose) + + await page.goBack() + await expect(page.getByText('Panel layer: fail')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page).toHaveURL('/layers/fail-recover') + + await page.goBack() + await expect(page).toHaveURL('/') + await expect(page.getByText('Panel layer: fail')).toBeHidden() +}) + +test('a cold layer closed while its base is slow restores to the base, never a blank', async ({ page }) => { + await page.goto('/') + // The base beneath it takes 1.5s, and Firefox does not fire load until the walk that fetches it + // has come back, which is the window this row is about. + await page.goto('/layers/slow-base-panel', { waitUntil: 'commit' }) + + await expect(page.getByText('Panel layer: slow')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page.getByText('Base page')).toBeHidden() + await expect(page).toHaveURL('/layers/base?delay=1500') + + await page.goBack() + await expect(page.getByText('Panel layer: slow')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + + await page.goForward() + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + + await page.goBack() + await expect(page.getByText('Panel layer: slow')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await page.goBack() + await expect(page).toHaveURL('/') +}) + +test('a layer that declares no base at all opened cold renders as a page', async ({ page }) => { + requests.listen(page) + await page.goto('/layers/promote-cold') + + await expect(page.getByText('Panel layer: promote')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + expect( + requests.requests.filter( + (request) => request.url().includes('/layers/') && !request.url().includes('/layers/promote-cold'), + ), + ).toHaveLength(0) + + const entry = await page.evaluate(() => window.history.state.page) + expect(entry.layer).toBeUndefined() +}) + +test('a cold open fetches the base with the version the layer arrived on', async ({ page }) => { + await page.goto('/layers/version/outer') + + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.getByText('Panel layer: version')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(1) +}) + +test('keeps a layer open across a partial reload of the base beneath it', async ({ page }) => { + await page.goto('/layers/counted/base') + await expect(page.getByText('Base page')).toBeVisible() + + // The layer opens with the address left on the base, so a reload re-asks for the base. + await clickAndWaitForResponse(page, 'Open panel without moving the address', '/layers/counted/panel', 'button') + await expect(page.getByText('Panel layer: counted')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page).toHaveURL('/layers/counted/base') + + const likesBefore = await page.locator('#likes').textContent() + + // The base is inert under the layer's dialog, so the reload is issued through the router. + const partialResponse = page.waitForResponse((response) => response.url().includes('/layers/counted/base')) + await page.evaluate(() => window.testing.Inertia.reload({ only: ['likes'] })) + await partialResponse + + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page.getByText('Panel layer: counted')).toBeVisible() + await expect(page.locator('#likes')).not.toHaveText(likesBefore ?? '') + await expect(page).toHaveURL('/layers/counted/base') +}) + +test('a layer-targeted partial names the layer in the header and merges into it, keeping a dropped prop', async ({ + page, +}) => { + await page.goto('/layers/counted/base') + await expect(page.getByText('Base page')).toBeVisible() + + await clickAndWaitForResponse(page, 'Open counted panel', '/layers/counted/panel') + await expect(page.getByText('Panel layer: counted')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page).toHaveURL('/layers/counted/panel') + + const countBefore = await page.locator('[data-layer-index="0"] [data-testid="panel-count"]').textContent() + const likesBefore = await page.locator('#likes').textContent() + + const responsePromise = page.waitForResponse((response) => response.url().includes('/layers/counted/panel')) + const requestPromise = page.waitForRequest((request) => request.url().includes('/layers/counted/panel')) + await page.getByRole('button', { exact: true, name: 'Partial reload panel' }).click() + const [request] = await Promise.all([requestPromise, responsePromise]) + expect(request.headers()['x-inertia-partial-component']).toBe('Layers/Panel') + + await expect(page.locator('[data-layer-index="0"] [data-testid="panel-count"]')).not.toHaveText(countBefore ?? '') + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page.getByText('Panel layer: counted')).toBeVisible() + await expect(page.locator('#likes')).toHaveText(likesBefore ?? '') + const props = await page.evaluate( + () => (window.history.state.page.layers as { props: Record }[])[0].props, + ) + expect(props).toMatchObject({ name: 'counted', tag: 'first' }) +}) + +test('a request made through the layer router targets the layer', async ({ page }) => { + await page.goto('/layers/base') + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.getByText('Panel layer: first')).toBeVisible() + + const countBefore = await page.locator('[data-layer-index="0"] [data-testid="panel-count"]').textContent() + const likesBefore = await page.locator('#likes').textContent() + + const requestPromise = page.waitForRequest((request) => request.url().includes('/layers/panel/first')) + const responsePromise = page.waitForResponse((response) => response.url().includes('/layers/panel/first')) + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Partial through the layer router' }).click() + const [request] = await Promise.all([requestPromise, responsePromise]) + + expect(request.headers()['x-inertia-partial-component']).toBe('Layers/Panel') + await expect(page.locator('[data-layer-index="0"] [data-testid="panel-count"]')).not.toHaveText(countBefore ?? '') + await expect(page.locator('#likes')).toHaveText(likesBefore ?? '') + await expect(page.locator('[data-layer-index]')).toHaveCount(1) +}) + +test('a bare reload from inside a layer refreshes the base beneath the stack', async ({ page }) => { + await page.goto('/layers/counted/base') + await expect(page.getByText('Base page')).toBeVisible() + + await clickAndWaitForResponse(page, 'Open counted panel', '/layers/counted/panel') + await expect(page.getByText('Panel layer: counted')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page).toHaveURL('/layers/counted/panel') + + const likesBefore = await page.locator('#likes').textContent() + + await clickAndWaitForResponse(page, 'Reload base beneath', '/layers/counted/base', 'button') + + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page.getByText('Panel layer: counted')).toBeVisible() + await expect(page.locator('#likes')).not.toHaveText(likesBefore ?? '') +}) + +test("a layer's replaceProp changes the layer's props, not the base's", async ({ page }) => { + await page.goto('/layers/base') + await expect(page.getByText('Base page')).toBeVisible() + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.getByText('Panel layer: first')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + + const countBefore = await page.locator('[data-layer-index="0"] [data-testid="panel-count"]').textContent() + + await page.getByRole('button', { exact: true, name: 'Layer replaceProp' }).click() + + await expect(page.locator('[data-layer-index="0"] [data-testid="panel-count"]')).toHaveText( + String(Number(countBefore ?? 0) + 1), + ) + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page.getByText('Panel layer: first')).toBeVisible() + await expect(page.locator('#likes')).toHaveText('0') + const props = await page.evaluate( + () => (window.history.state.page.layers as { props: Record }[])[0].props, + ) + expect(props).toMatchObject({ name: 'first' }) + const baseProps = await page.evaluate(() => window.history.state.page.props as Record) + expect(baseProps).toEqual({ likes: 0 }) +}) + +test("a remembered input inside a layer writes to the layer's bag, not the base's", async ({ page }) => { + await page.goto('/layers/base') + await expect(page.getByText('Base page')).toBeVisible() + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.getByText('Panel layer: first')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + + await page.locator('[data-layer-index="0"] .layer-remembered').fill('typed in the layer') + + const layerId = await page.evaluate(() => (window.history.state.page.layers as { id: string }[])[0].id) + + // Vue and Svelte store the whole form state under the remember key; React keys its data and + // errors separately under `${key}:data`. + await expect + .poll(() => + page.evaluate((id) => { + const restore = ( + window.testing as { Inertia: { restore: (key: string, layerId?: string) => unknown } } + ).Inertia.restore.bind((window.testing as { Inertia: unknown }).Inertia) + const state = restore('note', id) as { data?: { note?: string } } | undefined + const data = restore('note:data', id) as { note?: string } | undefined + + return state?.data?.note ?? data?.note + }, layerId), + ) + .toBe('typed in the layer') + await expect + .poll(() => + page.evaluate(() => { + const restore = (window.testing as { Inertia: { restore: (key: string) => unknown } }).Inertia.restore + + return [restore('note'), restore('note:data')] + }), + ) + .toEqual([undefined, undefined]) +}) + +test("a layer's deferred props load into the layer, not the base", async ({ page }) => { + await page.goto('/layers/base') + await expect(page.getByText('Base page')).toBeVisible() + + await clickAndWaitForResponse(page, 'Open deferred layer', '/layers/deferred') + await expect(page.getByText('Deferred layer: deferred')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page.locator('[data-testid="deferred-fallback"]')).toBeVisible() + + await expect(page.locator('[data-testid="deferred-stats"]')).toHaveText('1,2,3') + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page.getByText('Deferred layer: deferred')).toBeVisible() +}) + +test('a layer opened through a handle opens another, whose events reach the owner handle', async ({ page }) => { + await page.goto('/layers/base') + + await page.getByRole('button', { exact: true, name: 'Open handled panel' }).click() + await expect(page.getByText('Panel layer: first')).toBeVisible() + + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Open child layer' }).click() + await expect(page.getByText('Child layer', { exact: true })).toBeVisible() + await expect(page.locator('#layer-count')).toHaveText('2') + + await page.locator('[data-layer-index="1"]').getByRole('button', { name: 'Emit saved' }).click() + + await expect(page.locator('#child-events')).toHaveText('saved 5') +}) + +test('a layer emits to its owner and hears its children through its own useLayer()', async ({ page }) => { + await page.goto('/layers/base') + + await page.getByRole('button', { exact: true, name: 'Open handled panel' }).click() + await expect(page.getByText('Panel layer: first')).toBeVisible() + + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Open child layer' }).click() + await expect(page.getByText('Child layer', { exact: true })).toBeVisible() + + await page.locator('[data-layer-index="1"]').getByRole('button', { name: 'Emit saved' }).click() + + await expect(page.locator('#panel-child-events')).toHaveText('saved 5') + await expect(page.locator('#child-events')).toHaveText('saved 5') +}) + +test('going back to close a child leaves the layer beneath it hearing what it opens next', async ({ page }) => { + await page.goto('/layers/base') + + await page.getByRole('button', { exact: true, name: 'Open handled panel' }).click() + await expect(page.getByText('Panel layer: first')).toBeVisible() + + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Open child layer' }).click() + await expect(page.getByText('Child layer', { exact: true })).toBeVisible() + + await page.goBack() + + await expect(page.locator('#layer-count')).toHaveText('1') + await expect(page.getByText('Panel layer: first')).toBeVisible() + + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Open child layer' }).click() + await expect(page.getByText('Child layer', { exact: true })).toBeVisible() + await page.locator('[data-layer-index="1"]').getByRole('button', { name: 'Emit saved' }).click() + + await expect(page.locator('#panel-child-events')).toHaveText('saved 5') +}) + +test('a layer closes itself through its own useLayer()', async ({ page }) => { + await page.goto('/layers/base') + + await page.getByRole('button', { exact: true, name: 'Open handled panel' }).click() + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Open child layer' }).click() + await expect(page.locator('#layer-count')).toHaveText('2') + + await page.locator('[data-layer-index="1"]').getByRole('button', { name: 'Close myself' }).click() + + await expect(page.locator('#layer-count')).toHaveText('1') + await expect(page.getByText('Panel layer: first')).toBeVisible() +}) + +test("a layer opened through its owner's useLayer() closes itself, leaving its owner standing", async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Open child through the layer' }).click() + await expect(page.locator('#layer-count')).toHaveText('2') + + await page.locator('[data-layer-index="1"]').getByRole('button', { name: 'Close myself' }).click() + + await expect(page.locator('#layer-count')).toHaveText('1') + await expect(page.getByText('Panel layer: first')).toBeVisible() + await expect(page.getByText('Child layer', { exact: true })).toBeHidden() + await expect(page).toHaveURL('/layers/panel/first') +}) + +test('the handle layer.layer() returned closes the child, not the layer that opened it', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Open child through the layer' }).click() + await expect(page.locator('#layer-count')).toHaveText('2') + + await page.evaluate(() => void window.testing.layerChildHandle!.close()) + + await expect(page.locator('#layer-count')).toHaveText('1') + await expect(page.locator('[data-layer-index="0"]')).toContainText('Panel layer: first') + await expect(page).toHaveURL('/layers/panel/first') +}) + +test('the owner handle closes the child layer it opened', async ({ page }) => { + await page.goto('/layers/base') + + await page.getByRole('button', { exact: true, name: 'Open handled panel' }).click() + await expect(page.getByText('Panel layer: first')).toBeVisible() + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Open child layer' }).click() + await expect(page.getByText('Child layer', { exact: true })).toBeVisible() + + // An exit transition makes the closing window observable. + await page.addStyleTag({ content: 'dialog[data-layer-closing="true"] { opacity: 0; transition: opacity 400ms }' }) + await page.evaluate(() => void (window as any).testing.childHandle.close()) + await expect(page.locator('[data-layer-index="1"]')).toHaveAttribute('data-layer-closing', 'true') + await expect(page.getByText('Child layer', { exact: true })).toBeVisible() + + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page.getByText('Child layer', { exact: true })).toBeHidden() + await expect(page.locator('[data-layer-index="0"]')).toContainText('Panel layer: first') + await expect(page).toHaveURL('/layers/panel/first') +}) + +test('the page beneath keeps its own state when a layer closes', async ({ page }) => { + await page.goto('/layers/base') + await page.locator('#note').fill('typed on the page') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.locator('#note')).toHaveValue('typed on the page') + + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page.locator('#layer-count')).toHaveText('0') + await expect(page.locator('#note')).toHaveValue('typed on the page') +}) + +test('the page beneath is left alone when a layer closes out of the middle of a stack', async ({ page }) => { + await page.goto('/layers/base') + await page.locator('#note').fill('typed on the page') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await page.locator('[data-layer-index="0"] .panel-note').fill('typed in the panel') + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Open child layer' }).click() + await expect(page.locator('#layer-count')).toHaveText('2') + + await page.keyboard.press('Escape') + + await expect(page.locator('#layer-count')).toHaveText('1') + await expect(page.locator('#note')).toHaveValue('typed on the page') + await expect(page.locator('[data-layer-index="0"] .panel-note')).toHaveValue('typed in the panel') +}) + +test('a plain back onto the same component still remounts it, as it always did', async ({ page }) => { + await page.goto('/layers/base') + + await page.evaluate(() => window.testing.Inertia.visit('/layers/counted/base')) + await expect(page).toHaveURL('/layers/counted/base') + await page.locator('#note').fill('typed on the second page') + + await page.goBack() + + await expect(page).toHaveURL('/layers/base') + await expect(page.locator('#note')).toHaveValue('') +}) + +test('a local layer is a history step: back closes it, forward brings it back', async ({ page }) => { + await page.goto('/') + await page.goto('/layers/base') + + await page.getByRole('button', { exact: true, name: 'Open local layer' }).click() + await expect(page.getByText('Local layer', { exact: true })).toBeVisible() + await expect(page.locator('#local-note')).toHaveText('local') + await expect(page.locator('#local-errors')).toHaveText('object') + await expect(page).toHaveURL('/layers/base') + + await page.goBack() + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page.getByText('Base page')).toBeVisible() + await expect(page).toHaveURL('/layers/base') + + await page.goForward() + + await expect(page.getByText('Local layer', { exact: true })).toBeVisible() + await expect(page.locator('#local-note')).toHaveText('local') + await expect(page).toHaveURL('/layers/base') +}) + +test('a layer opened without moving the address is a history step too', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel without moving the address', '/layers/counted/panel', 'button') + await expect(page.getByText('Panel layer: counted')).toBeVisible() + await expect(page).toHaveURL('/layers/base') + + await page.goBack() + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page.getByText('Base page')).toBeVisible() + await expect(page).toHaveURL('/layers/base') + + await page.goForward() + + await expect(page.getByText('Panel layer: counted')).toBeVisible() + await expect(page).toHaveURL('/layers/base') +}) + +test('closing a layer above one that owns no address leaves it standing', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel without moving the address', '/layers/counted/panel', 'button') + await expect(page.getByText('Panel layer: counted')).toBeVisible() + + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Open child layer' }).click() + await expect(page.getByText('Child layer', { exact: true })).toBeVisible() + await expect(page.locator('#layer-count')).toHaveText('2') + + await page.keyboard.press('Escape') + + await expect(page.locator('#layer-count')).toHaveText('1') + await expect(page.getByText('Panel layer: counted')).toBeVisible() + await expect(page).toHaveURL('/layers/base') +}) + +test('an instant visit inside a layer renders in the layer, leaving the page beneath standing', async ({ page }) => { + await page.goto('/layers/panel/first') + await expect(page.getByText('Panel layer: first')).toBeVisible() + await expect(page.getByText('Base page')).toBeVisible() + + requests.listen(page) + await page.getByRole('button', { name: 'Instantly open the next panel' }).click() + + // The placeholder is up before the server has answered: the layer is still the only one on the + // stack, and the page beneath it was never torn down to make room for it. + await expect(page).toHaveURL('/layers/panel/second?delay=500') + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page.getByText('Base page')).toBeVisible() + + await expect(page.getByText('Panel layer: second')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page.getByText('Base page')).toBeVisible() + + // Nothing went back for the base, which is what a cold reopen would have had to do. + expect(requests.requests.filter((request) => request.url().includes('/layers/base'))).toHaveLength(0) +}) + +test('a layer that owns no address still reloads itself, not the page beneath', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel without moving the address', '/layers/counted/panel', 'button') + await expect(page.getByTestId('panel-count')).toHaveText('1') + + await clickAndWaitForResponse(page, 'Partial reload panel', '/layers/counted/panel', 'button') + + await expect(page.getByTestId('panel-count')).toHaveText('2') + await expect(page.getByText('Panel layer: counted')).toBeVisible() + await expect(page).toHaveURL('/layers/base') +}) + +test('a cancelled layer open fires the handle onClose', async ({ page }) => { + await page.goto('/layers/base') + + // The panel route holds its response, so the visit can be cancelled while it is still out. + await page.getByRole('button', { exact: true, name: 'Open cancelled panel' }).click() + await page.evaluate(() => (window as any).testing.Inertia.cancelAll()) + + await expect(page.locator('#cancelled-event')).toHaveText('closed') +}) + +test('a programmatic close refreshes the base beneath it', async ({ page }) => { + await page.goto('/layers/counted/base') + await expect(page.locator('#likes')).toHaveText('0') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.getByText('Panel layer: first')).toBeVisible() + + await page.keyboard.press('Escape') + + await expect(page.locator('#likes')).toHaveText('1') + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page.getByText('Panel layer: first')).toBeHidden() +}) + +test('closing a cold-opened layer refreshes the base the walk fetched beneath it', async ({ page }) => { + await page.goto('/') + await page.goto('/layers/counted/panel') + + await expect(page.getByText('Panel layer: counted')).toBeVisible() + await expect(page.locator('#likes')).toHaveText('0') + + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page.locator('#likes')).toHaveText('1') +}) + +test('a write issued alongside a close still lands when it outlives the unwind', async ({ page }) => { + await page.goto('/layers/base') + await expect(page.locator('#likes')).toHaveText('0') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Like and close' }).click() + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page).toHaveURL('/layers/base') + + // The write answers a second after the unwind stepped back over the layer's entry. + await expect(page.locator('#likes')).toHaveText('1', { timeout: 5000 }) +}) + +test('a background request in flight when a layer closes is left to finish', async ({ page }) => { + await page.goto('/layers/base') + await expect(page.locator('#likes')).toHaveText('0') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Like in the background and close' }).click() + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page.locator('#likes')).toHaveText('1', { timeout: 5000 }) +}) + +test('a close: true response closes the layer and the refresh brings the staged flash', async ({ page }) => { + await page.goto('/layers/close-flash/base') + await expect(page.locator('#page-flash')).toHaveText('') + const likesBefore = await page.locator('#likes').textContent() + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.getByText('Panel layer: first')).toBeVisible() + + // The close route stages a flash in a cookie and answers with `close: true`, which the client + // reads off the wire instead of installing. + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Save and close' }).click() + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page.getByText('Panel layer: first')).toBeHidden() + await expect(page.locator('#likes')).toHaveText(String(Number(likesBefore) + 1)) + await expect(page.locator('#page-flash')).toHaveText('Saved from the panel') +}) + +test('a layer open with viewTransition captures the layer, not the document', async ({ page, browserName }) => { + test.skip(browserName === 'firefox', 'Firefox does not support View Transitions API in CI') + + await page.goto('/layers/base') + await expect(page.getByText('Base page')).toBeVisible() + + // A layer captured as its own boundary gets a ::view-transition-group(inertia-layer-) rather + // than being folded into the root. Chromium does not surface that group on the transition's + // animations, so it is sampled through getComputedStyle instead. + await page.evaluate(() => { + const w = window as unknown as { __vtCount: number; __vtGroupWidths: string[] } + w.__vtCount = 0 + w.__vtGroupWidths = [] + const original = document.startViewTransition.bind(document) + document.startViewTransition = (callback) => { + w.__vtCount += 1 + const transition = original(callback) + transition.ready.then(async () => { + for (let i = 0; i < 20; i++) { + const groupName = `inertia-layer-${document.querySelector('[data-layer-id]')!.getAttribute('data-layer-id')}` + w.__vtGroupWidths.push( + getComputedStyle(document.documentElement, `::view-transition-group(${groupName})`).width, + ) + await new Promise((resolve) => setTimeout(resolve, 20)) + } + }) + return transition + } + }) + + await clickAndWaitForResponse(page, 'Open panel with view transition', '/layers/panel/first', 'button') + await expect(page.getByText('Panel layer: first')).toBeVisible() + + const layerId = await page.locator('[data-layer-id]').getAttribute('data-layer-id') + expect(layerId).toBeTruthy() + + // Without the wrapper's view-transition-name there is no group, and every sample reads `auto`. + await expect + .poll(() => + page.evaluate(() => + (window as unknown as { __vtGroupWidths: string[] }).__vtGroupWidths.some( + (width) => width !== 'auto' && width !== '', + ), + ), + ) + .toBe(true) + + await expect(page.evaluate(() => (window as unknown as { __vtCount: number }).__vtCount)).resolves.toBe(1) +}) + +test('a layer component keeps undeclared props off its root element', async ({ page }) => { + test.skip(process.env.PACKAGE !== 'vue3', 'Fallthrough attributes are a Vue concept') + + await page.goto('/layers/base') + await page.getByRole('link', { name: 'Open guarded panel' }).click() + await page.getByRole('button', { name: 'Complete the prompt' }).click() + + await expect(page.locator('#guarded-layer')).toBeVisible() + await expect(page.locator('#guarded-layer')).not.toHaveAttribute('note') +}) + +test('a layerless app renders no dialog at all', async ({ page }) => { + await page.goto('/layers/base') + await expect(page.locator('dialog')).toHaveCount(0) + await expect(page.locator('[data-layer-index]')).toHaveCount(0) +}) + +test('the layer dialog traps focus: Tab cycles within it, the background is unreachable', async ({ page }) => { + await page.goto('/layers/base') + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.getByText('Panel layer: first')).toBeVisible() + + await expect + .poll(() => page.evaluate(() => !!document.activeElement?.closest('dialog[data-layer-index="0"]'))) + .toBe(true) + + for (let i = 0; i < 6; i++) { + await page.keyboard.press('Tab') + + expect( + await page.evaluate(() => { + const active = document.activeElement + + // WebKit steps through on its way round the cycle, which reaches nothing. What has + // to hold is that no control behind the layer is ever focused. + return active === document.body || !!active?.closest('dialog[data-layer-index="0"]') + }), + ).toBe(true) + await expect(page.locator('#note')).not.toBeFocused() + } +}) + +test('closing a layer hands focus back to what opened it', async ({ page }) => { + await page.goto('/layers/base') + const trigger = page.getByRole('link', { name: 'Open panel', exact: true }) + await trigger.focus() + await page.keyboard.press('Enter') + await expect(page.getByText('Panel layer: first')).toBeVisible() + + await page.keyboard.press('Escape') + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + + await expect(trigger).toBeFocused() +}) + +test('Escape dismisses the top layer', async ({ page }) => { + await page.goto('/layers/base') + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page.getByText('Panel layer: first')).toBeHidden() + await expect(page.getByText('Base page')).toBeVisible() +}) + +test('Escape closes the top layer only; the one beneath stays open and modal', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Open next panel' }).click() + await expect(page.locator('[data-layer-index]')).toHaveCount(2) + + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page.getByText('Panel layer: second')).toBeHidden() + await expect(page.getByText('Panel layer: first')).toBeVisible() + await expect( + page.evaluate(() => document.querySelector('dialog[data-layer-index="0"]')?.matches(':modal')), + ).resolves.toBe(true) +}) + +test('an open layer locks the background scroll, and the lock lifts only when the last one closes', async ({ + page, +}) => { + await page.goto('/layers/base') + await page.evaluate(() => window.scrollTo(0, 300)) + expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0) + + // Inertness blocks clicks and focus but not scrolling, so the lock is explicit: the root carries + // overflow:hidden while any layer is open, ref-counted across the stack. + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await page.locator('[data-layer-index="0"]').getByRole('button', { name: 'Open next panel' }).click() + await expect(page.locator('[data-layer-index]')).toHaveCount(2) + await expect(page.evaluate(() => document.documentElement.style.overflow)).resolves.toBe('hidden') + + const before = await page.evaluate(() => window.scrollY) + await page.keyboard.press('PageDown') + await page.mouse.move(400, 300) + await page.mouse.wheel(0, 400) + await page.waitForTimeout(100) + expect(await page.evaluate(() => window.scrollY)).toBe(before) + + await page.keyboard.press('Escape') + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + await expect(page.evaluate(() => document.documentElement.style.overflow)).resolves.toBe('hidden') + + await page.keyboard.press('Escape') + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page.evaluate(() => document.documentElement.style.overflow)).resolves.toBe('') + const afterClose = await page.evaluate(() => window.scrollY) + await page.mouse.wheel(0, 400) + await page.waitForTimeout(100) + expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(afterClose) +}) + +test('the layer dialog is modal, has the dialog role, and is named after the layer it renders', async ({ page }) => { + await page.goto('/layers/base') + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + + await expect(page.getByRole('dialog', { name: 'Layers/Panel' })).toBeVisible() + await expect(page.evaluate(() => document.querySelector('dialog')?.matches(':modal'))).resolves.toBe(true) +}) + +test('a closing layer stays mounted until its exit completes, then is removed', async ({ page }) => { + await page.goto('/layers/base') + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + + // A 400ms exit transition makes the closing window observable. + await page.addStyleTag({ content: 'dialog[data-layer-closing="true"] { opacity: 0; transition: opacity 400ms }' }) + await page.keyboard.press('Escape') + + await expect(page.locator('dialog[data-layer-closing="true"]')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + + await page.waitForTimeout(150) + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) +}) + +test('a closing layer waits for an exit that animates a panel inside the dialog', async ({ page }) => { + await page.goto('/layers/base') + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + + await page.addStyleTag({ + content: 'dialog[data-layer-closing="true"] [data-layer-id] { opacity: 0; transition: opacity 400ms }', + }) + await page.keyboard.press('Escape') + + await page.waitForTimeout(150) + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) +}) + +test('an exit animation that holds its end state ends the close, rather than the exit timing out', async ({ page }) => { + await page.goto('/layers/base') + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + + // `forwards` keeps the animation listed once it has ended, which is what any real shell writes. + await page.addStyleTag({ + content: `@keyframes layer-out { to { opacity: 0 } } + dialog[data-layer-closing="true"] { animation: layer-out 400ms forwards }`, + }) + + const started = Date.now() + await page.keyboard.press('Escape') + + await page.waitForTimeout(150) + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + expect(Date.now() - started).toBeLessThan(1200) +}) + +test('an exit animation on a panel inside the dialog ends the close, rather than the exit timing out', async ({ + page, +}) => { + await page.goto('/layers/base') + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + + await page.addStyleTag({ + content: `@keyframes layer-out { to { opacity: 0 } } + dialog[data-layer-closing="true"] [data-layer-id] { animation: layer-out 200ms forwards }`, + }) + + const started = Date.now() + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + expect(Date.now() - started).toBeLessThan(1000) +}) + +test('an endless animation inside the dialog does not hold the exit open', async ({ page }) => { + await page.goto('/layers/base') + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + + await page.addStyleTag({ + content: `@keyframes layer-spin { to { transform: rotate(360deg) } } + @keyframes layer-out { to { opacity: 0 } } + dialog [data-layer-id] { animation: layer-spin 600ms linear infinite } + dialog[data-layer-closing="true"] { animation: layer-out 200ms forwards }`, + }) + + const started = Date.now() + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + expect(Date.now() - started).toBeLessThan(1000) +}) + +test('with a layer open, a reload shows the progress bar and Escape still closes the layer', async ({ page }) => { + await page.goto('/layers/base') + await clickAndWaitForResponse(page, 'Open panel', '/layers/panel/first') + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + + // Hold the base response so the reload's progress bar is observable. + let releaseTheBase!: () => void + const held = new Promise((resolve) => (releaseTheBase = resolve)) + await page.route('**/layers/base', async (route) => { + await held + await route.continue() + }) + + await page.evaluate(() => { + window.testing.Inertia.reload() + }) + + await expect(page.locator('#nprogress .bar')).toHaveCount(1) + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + + await page.keyboard.press('Escape') + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + + releaseTheBase() +}) + +test('completing an interstitial prompt opens the layer over the page it was requested from', async ({ page }) => { + await page.goto('/layers/base') + await expect(page.getByText('Base page')).toBeVisible() + + requests.listen(page) + await page.evaluate(() => { + ;(window as any).testing.guardedHandle = (window as any).testing.Inertia.layer('/layers/guarded') + }) + await expect(page.getByText('Sudo prompt')).toBeVisible() + await expect(page.getByText('Base page')).toBeHidden() + + await page.getByRole('button', { name: 'Complete the prompt' }).click() + + await expect(page.getByText('Guarded layer')).toBeVisible() + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.getByText('Sudo prompt')).toBeHidden() + expect(await page.evaluate(() => window.history.state.page.component)).toBe('Layers/Base') + await expect(page.locator('[data-layer-index]')).toHaveCount(1) + expect(requests.requests.filter((request) => request.url().includes('/layers/base'))).toHaveLength(0) + + await page.evaluate(() => window.testing.Inertia.close((window as any).testing.guardedHandle.id)) + await expect(page.locator('[data-layer-index]')).toHaveCount(0) +}) + +test('a layer open answered by an ordinary page closes the handle', async ({ page }) => { + await page.goto('/layers/base') + + await page.evaluate(() => { + const handle = window.testing.Inertia.layer('/layers/settings') + handle.onClose(() => { + ;(window as any).unmarkedHandleClosed = true + }) + }) + await expect(page.locator('#login-page')).toBeVisible() + + expect(await page.evaluate(() => (window as any).unmarkedHandleClosed)).toBe(true) +}) + +test('a visit made while the prompt is showing still opens the layer over the page beneath it', async ({ page }) => { + await page.goto('/layers/base') + + await page.evaluate(() => { + ;(window as any).testing.Inertia.layer('/layers/guarded') + }) + await expect(page.getByText('Sudo prompt')).toBeVisible() + + await page.context().addCookies([{ name: 'layers-confirmed', value: '1', domain: 'localhost', path: '/' }]) + await page.evaluate(() => window.testing.Inertia.visit('/layers/guarded')) + + await expect(page.getByText('Guarded layer')).toBeVisible() + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.getByText('Sudo prompt')).toBeHidden() + expect(await page.evaluate(() => window.history.state.page.component)).toBe('Layers/Base') +}) + +test('a second open after the prompt completes uses the page on screen, not the earlier one', async ({ page }) => { + await page.goto('/layers/base') + + await page.evaluate(() => { + ;(window as any).testing.guardedHandle = (window as any).testing.Inertia.layer('/layers/guarded') + }) + await expect(page.getByText('Sudo prompt')).toBeVisible() + await page.getByRole('button', { name: 'Complete the prompt' }).click() + await expect(page.getByText('Guarded layer')).toBeVisible() + + await page.evaluate(() => window.testing.Inertia.close((window as any).testing.guardedHandle.id)) + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await page.evaluate(() => window.testing.Inertia.visit('/layers/counted/base')) + // Both bases render Layers/Base, so the text is already on screen: the address is what says the + // second one has landed, and the layer below has to open over it rather than over the first. + await expect(page).toHaveURL('/layers/counted/base') + await page.evaluate(() => window.testing.Inertia.visit('/layers/guarded')) + await expect(page.getByText('Guarded layer')).toBeVisible() + expect(await page.evaluate(() => window.history.state.page.url)).toBe('/layers/counted/base') +}) + +test('navigating away from the prompt ends it, so the next open uses the page on screen', async ({ page }) => { + await page.goto('/layers/base') + + await page.evaluate(() => { + ;(window as any).testing.Inertia.layer('/layers/guarded') + }) + await expect(page.getByText('Sudo prompt')).toBeVisible() + + await page.evaluate(() => window.testing.Inertia.visit('/layers/counted/base')) + await expect(page.getByText('Base page')).toBeVisible() + await page.context().addCookies([{ name: 'layers-confirmed', value: '1', domain: 'localhost', path: '/' }]) + await page.evaluate(() => window.testing.Inertia.visit('/layers/guarded')) + await expect(page.getByText('Guarded layer')).toBeVisible() + expect(await page.evaluate(() => window.history.state.page.url)).toBe('/layers/counted/base') +}) + +test('reloading during the prompt forgets the page the layer was opened from', async ({ page }) => { + await page.goto('/layers/base') + + await page.evaluate(() => { + ;(window as any).testing.Inertia.layer('/layers/guarded') + }) + await expect(page.getByText('Sudo prompt')).toBeVisible() + + await page.reload() + await expect(page.getByText('Sudo prompt')).toBeVisible() + requests.listen(page) + await page.getByRole('button', { name: 'Complete the prompt' }).click() + await expect(page.getByText('Guarded layer')).toBeVisible() + + // The walk for the base is sent once the layer has landed, so it trails the render it follows. + await expect.poll(() => requests.requests.filter((request) => request.url().includes('/layers/base')).length).toBe(1) +}) + +test('a layer opened by a visit returns over the page beneath, and back skips the prompt', async ({ page }) => { + await page.goto('/layers/base') + + requests.listen(page) + await page.evaluate(() => window.testing.Inertia.visit('/layers/guarded')) + await expect(page.getByText('Sudo prompt')).toBeVisible() + await expect(page.getByText('Base page')).toBeHidden() + + await page.getByRole('button', { name: 'Complete the prompt' }).click() + + await expect(page.getByText('Guarded layer')).toBeVisible() + await expect(page.getByText('Base page')).toBeVisible() + expect(await page.evaluate(() => window.history.state.page.component)).toBe('Layers/Base') + expect(requests.requests.filter((request) => request.url().includes('/layers/base'))).toHaveLength(0) + + await page.goBack() + + await expect(page).toHaveURL('/layers/base') + await expect(page.getByText('Sudo prompt')).toBeHidden() + await expect(page.locator('[data-layer-index]')).toHaveCount(0) +}) + +test('a layer opened by a link returns over the page beneath, and back skips the prompt', async ({ page }) => { + await page.goto('/layers/base') + + await clickAndWaitForResponse(page, 'Open guarded panel', '/layers/guarded') + await expect(page.getByText('Sudo prompt')).toBeVisible() + + await page.getByRole('button', { name: 'Complete the prompt' }).click() + + await expect(page.getByText('Guarded layer')).toBeVisible() + await expect(page.getByText('Base page')).toBeVisible() + + await page.goBack() + + await expect(page).toHaveURL('/layers/base') + await expect(page.getByText('Sudo prompt')).toBeHidden() + await expect(page.locator('[data-layer-index]')).toHaveCount(0) +}) + +test('the back button from a layer opened through a prompt lands on the page, not the prompt', async ({ page }) => { + await page.goto('/layers/base') + + await page.evaluate(() => { + ;(window as any).testing.guardedHandle = (window as any).testing.Inertia.layer('/layers/guarded') + }) + await expect(page.getByText('Sudo prompt')).toBeVisible() + await page.getByRole('button', { name: 'Complete the prompt' }).click() + await expect(page.getByText('Guarded layer')).toBeVisible() + + await page.goBack() + await expect(page).toHaveURL('/layers/base') + await expect(page.getByText('Sudo prompt')).toBeHidden() + await expect(page.locator('[data-layer-index]')).toHaveCount(0) +}) + +test('a layer response that is also an interstitial still opens as a layer', async ({ page }) => { + await page.goto('/layers/base') + await page.context().addCookies([{ name: 'layers-confirmed', value: '1', domain: 'localhost', path: '/' }]) + + await page.evaluate(() => window.testing.Inertia.visit('/layers/guarded?mark')) + + await expect(page.getByText('Guarded layer')).toBeVisible() + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.getByText('Sudo prompt')).toBeHidden() + await expect(page.locator('[data-layer-index]')).toHaveCount(1) +}) + +test('an interstitial response with no layer open renders as an ordinary page', async ({ page }) => { + await page.goto('/layers/prompt') + + await expect(page.getByText('Sudo prompt')).toBeVisible() + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + expect(await page.evaluate(() => window.history.state.page.component)).toBe('Layers/Prompt') +}) + +// The rows below opt into the loading placeholder. The test apps resolve one only for bases under +// /layers/loading, and record every consultation, so the rows above keep today's blank. + +test('the loading placeholder renders beneath a cold-opened layer until the walk lands the base', async ({ page }) => { + // Firefox does not fire load while the walk's request is out, so the navigation waits for commit. + await page.goto('/layers/loading/panel', { waitUntil: 'commit' }) + + await expect(page.getByText('Panel layer: loading')).toBeVisible() + await expect(page.locator('#loading-base')).toBeVisible() + await expect(page.getByText('Base page')).toBeHidden() + expect(await page.evaluate(() => window.loadingResolved)).toBe('/layers/loading/base?delay=1500|loading-panel') + + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.locator('#loading-base')).toHaveCount(0) + await expect(page.getByText('Panel layer: loading')).toBeVisible() +}) + +test('a layer dismissed while its base is loading leaves the placeholder, and the base swaps it in', async ({ + page, +}) => { + await page.goto('/layers/loading/panel', { waitUntil: 'commit' }) + await expect(page.locator('#loading-base')).toBeVisible() + + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page.locator('#loading-base')).toBeVisible() + + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.locator('#loading-base')).toHaveCount(0) +}) + +test('a stack opened cold renders one placeholder, not one per layer', async ({ page }) => { + await page.goto('/layers/loading/chain/outer', { waitUntil: 'commit' }) + + await expect(page.locator('[data-layer-index]')).toHaveCount(2) + await expect(page.locator('#loading-base')).toHaveCount(1) + + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.locator('#loading-base')).toHaveCount(0) +}) + +test('closing the layer while the base component is still importing still leaves the base on screen', async ({ + page, +}) => { + // The walk is held so the dismissal can be timed: once it answers, the base's component import + // (which the resolver delays) is what holds the landing open, and the Escape lands inside that + // window, superseding it. The walk must then be sent again rather than left spent. + let releaseWalk!: () => void + const held = new Promise((resolve) => (releaseWalk = resolve)) + await page.route('**/layers/loading/slow-import', async (route) => { + await held + await route.continue() + }) + + await page.goto('/layers/loading/slow-import/panel', { waitUntil: 'commit' }) + await expect(page.locator('#loading-base')).toBeVisible() + + const answered = page.waitForResponse('**/layers/loading/slow-import') + releaseWalk() + await answered + await page.keyboard.press('Escape') + + await expect(page.locator('[data-layer-index]')).toHaveCount(0) + await expect(page.getByText('Slow import base')).toBeVisible() + await expect(page.locator('#loading-base')).toHaveCount(0) +}) + +test('a layer opened over a page on screen never consults the loading resolver', async ({ page }) => { + await page.goto('/layers/loading/base') + await expect(page.getByText('Base page')).toBeVisible() + + await page.evaluate(() => window.testing.Inertia.visit('/layers/loading/panel')) + + await expect(page.getByText('Panel layer: loading')).toBeVisible() + await expect(page.locator('#loading-base')).toHaveCount(0) + expect(await page.evaluate(() => window.loadingResolved)).toBeUndefined() +}) + +test('an ordinary navigation never consults the loading resolver, not even before the app initialises', async ({ + page, +}) => { + await page.goto('/layers/loading/base') + + await expect(page.getByText('Base page')).toBeVisible() + await expect(page.locator('#loading-base')).toHaveCount(0) + expect(await page.evaluate(() => window.loadingResolved)).toBeUndefined() + + await page.evaluate(() => window.testing.Inertia.visit('/layers/base')) + + await expect(page).toHaveURL('/layers/base') + await expect(page.getByText('Base page')).toBeVisible() + expect(await page.evaluate(() => window.loadingResolved)).toBeUndefined() +}) diff --git a/tests/ssr.spec.ts b/tests/ssr.spec.ts index 5c38ad965..860e8e14c 100644 --- a/tests/ssr.spec.ts +++ b/tests/ssr.spec.ts @@ -305,3 +305,114 @@ test.describe('SSR Auto Transform', () => { }) }) }) + +test.describe('SSR layers', () => { + test('it renders a cold layer inside its shell, over a base that is not there', async ({ page }) => { + const response = await page.request.get('/ssr/layer') + const html = await response.text() + + expect(html).toContain(' { + const response = await page.request.get('/ssr/layer-head') + const html = await response.text() + + expect(html).toContain('Layer Head SSR') + expect(html).toContain('') + }) + + test('it renders a layer that remembers state, which the server has no history to write to', async ({ page }) => { + const response = await page.request.get('/ssr/layer') + + expect(response.status()).toBe(200) + expect(await response.text()).toContain('data-testid="ssr-layer-note"') + }) + + test('it hands the client the layer response untouched, so the client walks the chain itself', async ({ page }) => { + const response = await page.request.get('/ssr/layer') + const html = await response.text() + const embedded = html.match(/