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
4 changes: 2 additions & 2 deletions src/components/routerLink.browser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { visibilityObserverKey } from '@/compositions/useVisibilityObserver'
import { VisibilityObserver } from '@/services/createVisibilityObserver'
import { UrlString } from '@/types/urlString'
import { RouterPushOptions } from '@/types/routerPush'
import { ParentPropsAbandonedError, RouterLink } from '@/main'
import { NavigationAbandonedError, RouterLink } from '@/main'

test('renders an anchor tag with the correct href and slot content', () => {
const path = '/path/[paramName]'
Expand Down Expand Up @@ -1328,7 +1328,7 @@ describe('prefetch props', () => {
await flushPromises()

expect(caught).toHaveLength(1)
expect(caught[0]).toBeInstanceOf(ParentPropsAbandonedError)
expect(caught[0]).toBeInstanceOf(NavigationAbandonedError)

warn.mockRestore()
})
Expand Down
25 changes: 7 additions & 18 deletions src/compositions/usePrefetching.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { InjectionKey, MaybeRefOrGetter, ref, Ref, toValue, watch } from 'vue'
import { InjectionKey, MaybeRefOrGetter, onScopeDispose, ref, Ref, toValue, watch } from 'vue'
import { createUsePropStore } from '@/compositions/usePropStore'
import type { PrefetchConfigs, PrefetchStrategy } from '@/types/prefetch'
import { getPrefetchOption } from '@/utilities/prefetch'
Expand All @@ -7,8 +7,6 @@ import { isAsyncComponent } from '@/utilities/components'
import { useVisibilityObserver } from './useVisibilityObserver'
import { useEventListener } from './useEventListener'
import { Router } from '@/types/router'
import { PropsResult } from '@/utilities/props'
import { MaybePromise } from '@/types/utilities'

type UsePrefetchingConfig = PrefetchConfigs & {
route: ResolvedRoute | undefined,
Expand All @@ -25,26 +23,20 @@ export function createUsePrefetching<TRouter extends Router>(routerKey: Injectio
const usePropStore = createUsePropStore(routerKey)

return (config) => {
const prefetchedProps = new Map<PrefetchStrategy, Record<string, MaybePromise<PropsResult>>>()
const element = ref<HTMLElement>()

const { getPrefetchProps, setPrefetchProps } = usePropStore()
const { createPrefetchStore } = usePropStore()
const store = createPrefetchStore()
const { isElementVisible } = useVisibilityObserver(element)

const commit: UsePrefetching['commit'] = () => {
setPrefetchProps(getAccumulatedProps())
store.commit()
}

function getAccumulatedProps(): Record<string, MaybePromise<PropsResult>> {
return Array.from(prefetchedProps.values()).reduce<Record<string, MaybePromise<PropsResult>>>((accumulator, value) => {
Object.assign(accumulator, value)

return accumulator
}, {})
}
onScopeDispose(() => store.dispose())

watch(() => toValue(config), ({ route, ...configs }) => {
prefetchedProps.clear()
store.reset()

if (!route) {
return
Expand Down Expand Up @@ -78,10 +70,7 @@ export function createUsePrefetching<TRouter extends Router>(routerKey: Injectio

function doPrefetchingForStrategy(strategy: PrefetchStrategy, route: ResolvedRoute, configs: PrefetchConfigs): void {
prefetchComponentsForRoute(strategy, route, configs)

if (!prefetchedProps.has(strategy)) {
prefetchedProps.set(strategy, getPrefetchProps(strategy, route, configs, getAccumulatedProps()))
}
store.prefetch(strategy, route, configs)
}

return {
Expand Down
8 changes: 8 additions & 0 deletions src/errors/navigationAbandonedError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Thrown when data a getter is waiting on is discarded because navigation moved elsewhere.
*/
export class NavigationAbandonedError extends Error {
public constructor() {
super('Discarded before it was computed because navigation moved elsewhere.')
}
}
8 changes: 0 additions & 8 deletions src/errors/parentPropsAbandonedError.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export * from './types/useLink'
// Errors
export { DuplicateParamsError } from './errors/duplicateParamsError'
export { MetaPropertyConflict } from './errors/metaPropertyConflict'
export { ParentPropsAbandonedError } from './errors/parentPropsAbandonedError'
export { NavigationAbandonedError } from './errors/navigationAbandonedError'
export { RouterNotInstalledError } from './errors/routerNotInstalledError'
export { UseRouteInvalidError } from './errors/useRouteInvalidError'

Expand Down
56 changes: 56 additions & 0 deletions src/services/createNavigationStores.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, test } from 'vitest'
import { createNavigationStores } from './createNavigationStores'
import { createDataStore } from './createDataStore'
import { NavigationAbandonedError } from '@/errors/navigationAbandonedError'

describe('promote', () => {
test('adopts the staged store', () => {
const stores = createNavigationStores()
const staged = createDataStore()

stores.stage(staged)
stores.promote()

expect(stores.current()).toBe(staged)
})

test('starts a fresh store when nothing was staged', () => {
const stores = createNavigationStores()
const before = stores.current()

stores.promote()

expect(stores.current()).not.toBe(before)
})

test('hands back the store it replaced', () => {
const stores = createNavigationStores()
const before = stores.current()

expect(stores.promote()).toBe(before)
})

test('only adopts a staged store once', () => {
const stores = createNavigationStores()
const staged = createDataStore()

stores.stage(staged)
stores.promote()
stores.promote()

expect(stores.current()).not.toBe(staged)
})
})

describe('stage', () => {
test('disposes a store it displaces, so anything waiting on it resumes', async () => {
const stores = createNavigationStores()
const displaced = createDataStore()
const waiting = displaced.subscribe('key')

stores.stage(displaced)
stores.stage(createDataStore())

await expect(waiting).rejects.toThrow(NavigationAbandonedError)
})
})
65 changes: 65 additions & 0 deletions src/services/createNavigationStores.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { createDataStore, DataStore } from './createDataStore'
import { NavigationAbandonedError } from '@/errors/navigationAbandonedError'
import { ResolvedRoute } from '@/types/resolved'

/**
* Which store the rendered route reads from, and which one the next navigation will adopt.
*
* A navigation gets its own store so the one it replaces can be disposed outright. A followed link stages
* the store it prefetched into, which the navigation it triggers picks up — staged rather than handed over
* directly because a link is followed before that navigation starts.
*/
export type NavigationStores = {
/**
* The store the rendered route reads props from.
*/
current: () => DataStore,
/**
* Parks a followed link's store for the navigation it triggered. A store already parked is disposed,
* since following a second link before the first navigation arrives abandons the first.
*/
stage: (store: DataStore) => void,
/**
* Swaps in the staged store, or a fresh one, and hands back the store being replaced for disposal.
*/
promote: () => DataStore,
}

/**
* The store in use, and the one a followed link left for the next navigation. Staging only happens when a
* link is followed, so starting the router, pushing directly and history navigation all leave it unset.
*/
type Stores = {
current: DataStore,
staged?: DataStore,
}

export function createNavigationStores(): NavigationStores {
const stores: Stores = { current: createDataStore() }

const current: NavigationStores['current'] = () => stores.current

const stage: NavigationStores['stage'] = (store) => {
stores.staged?.dispose(new NavigationAbandonedError())
stores.staged = store
}

const promote: NavigationStores['promote'] = () => {
const previous = stores.current

stores.current = stores.staged ?? createDataStore()
stores.staged = undefined

return previous
}

return {
current,
stage,
promote,
}
}

export function getDataKey(id: string, name: string, route: ResolvedRoute): string {
return [id, name, route.id, JSON.stringify(route.params)].join('-')
}
Loading
Loading