Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 67 additions & 23 deletions packages/core/src/eventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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<string, string[]> = {}
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<string, string[]> = {}

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()
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/events.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { addressOf } from './layers'
import { GlobalEventDetails, GlobalEventNames, GlobalEventTrigger } from './types'

function fireEvent<TEventName extends GlobalEventNames>(
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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) => {
Expand Down
30 changes: 23 additions & 7 deletions packages/core/src/head.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -17,8 +18,11 @@ export function resolveServerHead(page: Page, serverHead?: ServerHeadOption): st
return []
}

// The top layer's head wins, exactly as a <Head> 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 []
Expand Down Expand Up @@ -96,15 +100,18 @@ export default function createHeadManager(
titleCallback: (title: string) => string,
onUpdate: HeadManagerOnUpdateCallback,
initialServerHead: Array<string> = [],
stack: () => LayerState[] = () => [],
): HeadManager {
const states: Record<string, Array<string>> = initialServerHead.length
? { [serverHeadProviderId]: initialServerHead }
: {}
const providers: Record<string, string | undefined> = {}
let lastProviderId = 0

function connect(): string {
function connect(layerId?: string): string {
const id = (lastProviderId += 1)
states[id] = []
providers[id] = layerId
return id.toString()
}

Expand All @@ -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
}
}

Expand All @@ -144,8 +153,15 @@ export default function createHeadManager(
function collect(): Array<string> {
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<string, string> = {
Expand Down Expand Up @@ -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),
}
Expand Down
Loading