diff --git a/packages/router-core/package.json b/packages/router-core/package.json index 7d1a456e2b..d7517044a7 100644 --- a/packages/router-core/package.json +++ b/packages/router-core/package.json @@ -41,10 +41,8 @@ "dependencies": { "@babel/runtime": "^7.16.7", "@solidjs/reactivity": "^0.0.7", - "history": "^5.2.0", "immer": "^9.0.15", - "tiny-invariant": "^1.3.1", - "zustand": "^4.3.2" + "tiny-invariant": "^1.3.1" }, "devDependencies": { "babel-plugin-transform-async-to-promises": "^0.8.18" diff --git a/packages/router-core/src/history.ts b/packages/router-core/src/history.ts new file mode 100644 index 0000000000..6b1ff9fa07 --- /dev/null +++ b/packages/router-core/src/history.ts @@ -0,0 +1,199 @@ +// While the public API was clearly inspired by the "history" npm package, +// This implementation attempts to be more lightweight by +// making assumptions about the way TanStack Router works + +export interface RouterHistory { + location: RouterLocation + listen: (cb: () => void) => () => void + push: (path: string, state: any) => void + replace: (path: string, state: any) => void + go: (index: number) => void + back: () => void + forward: () => void +} + +export interface ParsedPath { + href: string + pathname: string + search: string + hash: string +} + +export interface RouterLocation extends ParsedPath { + state: any +} + +const popStateEvent = 'popstate' + +function createHistory(opts: { + getLocation: () => RouterLocation + listener: (onUpdate: () => void) => () => void + pushState: (path: string, state: any) => void + replaceState: (path: string, state: any) => void + go: (n: number) => void + back: () => void + forward: () => void +}): RouterHistory { + let currentLocation = opts.getLocation() + let unsub = () => {} + let listeners = new Set<() => void>() + + const onUpdate = () => { + currentLocation = opts.getLocation() + + listeners.forEach((listener) => listener()) + } + + return { + get location() { + return currentLocation + }, + listen: (cb: () => void) => { + if (listeners.size === 0) { + unsub = opts.listener(onUpdate) + } + listeners.add(cb) + + return () => { + listeners.delete(cb) + if (listeners.size === 0) { + unsub() + } + } + }, + push: (path: string, state: any) => { + opts.pushState(path, state) + onUpdate() + }, + replace: (path: string, state: any) => { + opts.replaceState(path, state) + onUpdate() + }, + go: (index) => { + opts.go(index) + onUpdate() + }, + back: () => { + opts.back() + onUpdate() + }, + forward: () => { + opts.forward() + onUpdate() + }, + } +} + +export function createBrowserHistory(opts?: { + getHref?: () => string + createHref?: (path: string) => string +}): RouterHistory { + const getHref = + opts?.getHref ?? + (() => + `${window.location.pathname}${window.location.hash}${window.location.search}`) + const createHref = opts?.createHref ?? ((path) => path) + const getLocation = () => parseLocation(getHref(), history.state) + + return createHistory({ + getLocation, + listener: (onUpdate) => { + window.addEventListener(popStateEvent, onUpdate) + return () => { + window.removeEventListener(popStateEvent, onUpdate) + } + }, + pushState: (path, state) => { + window.history.pushState( + { ...state, key: createRandomKey() }, + '', + createHref(path), + ) + }, + replaceState: (path, state) => { + window.history.replaceState( + { ...state, key: createRandomKey() }, + '', + createHref(path), + ) + }, + back: () => window.history.back(), + forward: () => window.history.forward(), + go: (n) => window.history.go(n), + }) +} + +export function createHashHistory(): RouterHistory { + return createBrowserHistory({ + getHref: () => window.location.hash.substring(1), + createHref: (path) => `#${path}`, + }) +} + +export function createMemoryHistory( + opts: { + initialEntries: string[] + initialIndex?: number + } = { + initialEntries: ['/'], + }, +): RouterHistory { + const entries = opts.initialEntries + let index = opts.initialIndex ?? entries.length - 1 + let currentState = {} + + const getLocation = () => parseLocation(entries[index]!, currentState) + + return createHistory({ + getLocation, + listener: (onUpdate) => { + window.addEventListener(popStateEvent, onUpdate) + // We might need to handle the hashchange event in the future + // window.addEventListener(hashChangeEvent, onUpdate) + return () => { + window.removeEventListener(popStateEvent, onUpdate) + } + }, + pushState: (path, state) => { + currentState = { + ...state, + key: createRandomKey(), + } + entries.push(path) + index++ + }, + replaceState: (path, state) => { + currentState = { + ...state, + key: createRandomKey(), + } + entries[index] = path + }, + back: () => { + index-- + }, + forward: () => { + index = Math.min(index + 1, entries.length - 1) + }, + go: (n) => window.history.go(n), + }) +} + +function parseLocation(href: string, state: any): RouterLocation { + let hashIndex = href.indexOf('#') + let searchIndex = href.indexOf('?') + const pathEnd = Math.min(hashIndex, searchIndex) + + return { + href, + pathname: pathEnd > -1 ? href.substring(0, pathEnd) : href, + hash: hashIndex > -1 ? href.substring(hashIndex, searchIndex) : '', + search: searchIndex > -1 ? href.substring(searchIndex) : '', + state, + } +} + +// Thanks co-pilot! +function createRandomKey() { + return (Math.random() + 1).toString(36).substring(7) +} diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 997b1c6529..f78ca13a82 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -1,4 +1,5 @@ export { default as invariant } from 'tiny-invariant' +export * from './history' export * from './frameworks' export * from './link' diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 260bfcdd54..8e75912132 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -47,6 +47,11 @@ import { Updater, } from './utils' import { replaceEqualDeep } from './interop' +import { + createBrowserHistory, + createMemoryHistory, + RouterHistory, +} from './history' export interface RegisterRouter { // router: Router @@ -98,6 +103,7 @@ export interface RouterOptions< TRouteConfig extends AnyRouteConfig, TRouterContext, > { + history?: RouterHistory stringifySearch?: SearchSerializer parseSearch?: SearchParser filterRoutes?: FilterRoutesFn @@ -283,6 +289,15 @@ export class Router< AllRouteInfo: TAllRouteInfo } + options: PickAsRequired< + RouterOptions, + 'stringifySearch' | 'parseSearch' | 'context' + > + history: RouterHistory + basepath: string + // __location: Location + routeTree!: Route + routesById!: RoutesById navigateTimeout: undefined | Timeout nextAction: undefined | 'push' | 'replace' navigationPromise: undefined | Promise @@ -291,44 +306,36 @@ export class Router< startedLoadingAt = Date.now() resolveNavigation = () => {} - constructor(userOptions?: RouterOptions) { - const originalOptions = { + constructor(options?: RouterOptions) { + this.options = { defaultLoaderGcMaxAge: 5 * 60 * 1000, defaultLoaderMaxAge: 0, defaultPreloadMaxAge: 2000, defaultPreloadDelay: 50, context: undefined!, - ...userOptions, - stringifySearch: userOptions?.stringifySearch ?? defaultStringifySearch, - parseSearch: userOptions?.parseSearch ?? defaultParseSearch, - fetchServerDataFn: - userOptions?.fetchServerDataFn ?? defaultFetchServerDataFn, + ...options, + stringifySearch: options?.stringifySearch ?? defaultStringifySearch, + parseSearch: options?.parseSearch ?? defaultParseSearch, + fetchServerDataFn: options?.fetchServerDataFn ?? defaultFetchServerDataFn, } + this.history = + this.options?.history ?? isServer + ? createMemoryHistory() + : createBrowserHistory() this.store = createStore(getInitialRouterState()) - - this.store = this.store - this.options = originalOptions this.basepath = '' - this.update(userOptions) + this.update(options) // Allow frameworks to hook into the router creation this.options.createRouter?.(this) } - // Public API - options: PickAsRequired< - RouterOptions, - 'stringifySearch' | 'parseSearch' | 'context' - > - basepath: string - // __location: Location - routeTree!: Route - routesById!: RoutesById reset = () => { this.store.setState((s) => Object.assign(s, getInitialRouterState())) } + mount = () => { // Mount only does anything on the client if (!isServer) { @@ -337,11 +344,10 @@ export class Router< this.load() } - const cb = () => { + const unsubHistory = this.history.listen(() => { this.load(this.#parseLocation(this.store.state.latestLocation)) - } + }) - const popStateEvent = 'popstate' const visibilityChangeEvent = 'visibilitychange' const focusEvent = 'focus' @@ -350,15 +356,15 @@ export class Router< // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (window.addEventListener) { // Listen to visibilitychange and focus - window.addEventListener(popStateEvent, cb) window.addEventListener(visibilityChangeEvent, this.#onFocus, false) window.addEventListener(focusEvent, this.#onFocus, false) } return () => { + unsubHistory() if (window.removeEventListener) { // Be sure to unsubscribe if a new handler is set - window.removeEventListener(popStateEvent, cb) + window.removeEventListener(visibilityChangeEvent, this.#onFocus) window.removeEventListener(focusEvent, this.#onFocus) } @@ -367,6 +373,7 @@ export class Router< return () => {} } + update = < TRouteConfig extends RouteConfig = RouteConfig, TAllRouteInfo extends AnyAllRouteInfo = AllRouteInfo, @@ -416,6 +423,7 @@ export class Router< __postSearchFilters, }) } + cancelMatches = () => { ;[ ...this.store.state.currentMatches, @@ -424,6 +432,7 @@ export class Router< match.cancel() }) } + load = async (next?: ParsedLocation) => { let now = Date.now() const startedAt = now @@ -549,6 +558,7 @@ export class Router< this.resolveNavigation() } + cleanMatchCache = () => { const now = Date.now() @@ -571,6 +581,7 @@ export class Router< }) }) } + getRoute = ( id: TId, ): Route => { @@ -580,6 +591,7 @@ export class Router< return route } + loadRoute = async ( navigateOpts: BuildNextOptions = this.store.state.latestLocation, ): Promise => { @@ -590,6 +602,7 @@ export class Router< await this.loadMatches(matches) return matches } + preloadRoute = async ( navigateOpts: BuildNextOptions = this.store.state.latestLocation, loaderOpts: { maxAge?: number; gcMaxAge?: number }, @@ -614,6 +627,7 @@ export class Router< }) return matches } + matchRoutes = (pathname: string, opts?: { strictParseParams?: boolean }) => { const matches: RouteMatch[] = [] @@ -712,6 +726,7 @@ export class Router< return matches } + loadMatches = async ( resolvedMatches: RouteMatch[], loaderOpts?: @@ -764,6 +779,7 @@ export class Router< await Promise.all(matchPromises) } + loadMatchData = async ( routeMatch: RouteMatch, ): Promise> => { @@ -790,6 +806,7 @@ export class Router< return this.options.fetchServerDataFn!({ router: this, routeMatch }) } } + invalidateRoute = async (opts: MatchLocation) => { const next = this.buildNext(opts) const unloadedMatchIds = this.matchRoutes(next.pathname).map((d) => d.id) @@ -805,6 +822,7 @@ export class Router< }), ) } + reload = () => { this.navigate({ fromCurrent: true, @@ -812,9 +830,11 @@ export class Router< search: true, } as any) } + resolvePath = (from: string, path: string) => { return resolvePath(this.basepath!, from, cleanPath(path)) } + navigate = async < TFrom extends ValidFromPath = '/', TTo extends string = '.', @@ -855,6 +875,7 @@ export class Router< params, }) } + matchRoute = < TFrom extends ValidFromPath = '/', TTo extends string = '.', @@ -900,6 +921,7 @@ export class Router< }, ) as any } + buildLink = < TFrom extends ValidFromPath = '/', TTo extends string = '.', @@ -1037,6 +1059,7 @@ export class Router< disabled, } } + dehydrate = (): DehydratedRouter => { return { state: { @@ -1061,6 +1084,7 @@ export class Router< context: this.options.context as TRouterContext, } } + hydrate = (dehydratedRouter: DehydratedRouter) => { this.store.setState((s) => { // Update the context TODO: make this part of state? @@ -1088,6 +1112,7 @@ export class Router< Object.assign(s, { ...dehydratedRouter.state, currentMatches }) }) } + getLoader = (opts: { from: TFrom }): unknown extends TAllRouteInfo['routeInfoById'][TFrom]['routeLoaderData'] @@ -1147,6 +1172,7 @@ export class Router< return loader as any } + #buildRouteTree = (rootRouteConfig: RouteConfig) => { const recurseRoutes = ( routeConfigs: RouteConfig[], @@ -1184,24 +1210,37 @@ export class Router< return routes[0]! } + #parseLocation = (previousLocation?: ParsedLocation): ParsedLocation => { - let { pathname, search, hash } = window.location - let state = window.history.state || {} + let { pathname, search, hash, state } = this.history.location + const parsedSearch = this.options.parseSearch(search) + console.log({ + pathname: pathname, + searchStr: search, + search: replaceEqualDeep(previousLocation?.search, parsedSearch), + hash: hash.split('#').reverse()[0] ?? '', + href: `${pathname}${search}${hash}`, + state: state as LocationState, + key: state?.key || '__init__', + }) + return { pathname: pathname, searchStr: search, search: replaceEqualDeep(previousLocation?.search, parsedSearch), hash: hash.split('#').reverse()[0] ?? '', href: `${pathname}${search}${hash}`, - state: state.usr as LocationState, - key: state.key || '__init__', + state: state as LocationState, + key: state?.key || '__init__', } } + #onFocus = () => { this.load() } + #buildLocation = (dest: BuildNextOptions = {}): ParsedLocation => { const fromPathname = dest.fromCurrent ? this.store.state.latestLocation.pathname @@ -1285,6 +1324,7 @@ export class Router< key: dest.key, } } + #commitLocation = (location: BuildNextOptions & { replace?: boolean }) => { const next = this.buildNext(location) const id = '' + Date.now() + Math.random() @@ -1297,7 +1337,7 @@ export class Router< nextAction = 'push' } - const isSameUrl = window.location.href === next.href + const isSameUrl = this.store.state.latestLocation.href === next.href if (isSameUrl && !next.key) { nextAction = 'replace' @@ -1307,14 +1347,10 @@ export class Router< next.hash ? `#${next.hash}` : '' }` - window.history[nextAction === 'push' ? 'pushState' : 'replaceState']( - { - id, - ...next.state, - }, - '', - href, - ) + this.history[nextAction === 'push' ? 'push' : 'replace'](href, { + id, + ...next.state, + }) this.load(this.#parseLocation(this.store.state.latestLocation))