import type { Injector, Token } from '@furystack/inject' import { defineService } from '@furystack/inject' import { deserializeQueryString as defaultDeserializeQueryString, serializeToQueryString as defaultSerializeToQueryString, } from '@furystack/rest' import { ObservableValue } from '@furystack/utils' /** * Overridable settings for {@link LocationService}. Bind a replacement factory * via {@link useCustomSearchStateSerializer} to swap the URL query-string * (de)serializers used by the service. */ export interface LocationServiceSettings { serialize: typeof defaultSerializeToQueryString deserialize: typeof defaultDeserializeQueryString } export const LocationServiceSettings: Token = defineService({ name: '@furystack/shades/LocationServiceSettings', lifetime: 'singleton', factory: () => ({ serialize: defaultSerializeToQueryString, deserialize: defaultDeserializeQueryString, }), }) /** * Singleton service that tracks browser location changes (pathname, search, hash) * and exposes them as observable values for reactive routing and URL-driven state. */ export interface LocationService { readonly deserializeQueryString: typeof defaultDeserializeQueryString readonly onLocationPathChanged: ObservableValue readonly onLocationHashChanged: ObservableValue readonly onLocationSearchChanged: ObservableValue readonly onDeserializedLocationSearchChanged: ObservableValue> readonly searchParamObservables: Map> /** * Synchronizes the observable state with the current browser location. * Called internally after navigation events and history state changes. */ updateState(): void /** * Navigate to a path. Use this instead of raw history.pushState for SPA routing. */ navigate(path: string): void /** * Replace the current history entry with a new path. Use this instead of raw * history.replaceState for SPA redirects where the intermediate URL should * not appear in the browser's back/forward stack. */ replace(path: string): void /** * Gets or creates an observable for a search parameter. */ useSearchParam(key: string, defaultValue: T): ObservableValue } export const LocationService: Token = defineService({ name: '@furystack/shades/LocationService', lifetime: 'singleton', factory: ({ inject, onDispose }) => { const { serialize, deserialize } = inject(LocationServiceSettings) const onLocationPathChanged = new ObservableValue(new URL(location.href).pathname) const onLocationHashChanged = new ObservableValue(location.hash.replace('#', '')) const onLocationSearchChanged = new ObservableValue(location.search) const onDeserializedLocationSearchChanged = new ObservableValue>( deserialize(location.search), ) const searchParamObservables = new Map>() const updateState = (): void => { onLocationPathChanged.setValue(location.pathname) onLocationHashChanged.setValue(location.hash.replace('#', '')) onLocationSearchChanged.setValue(location.search) } const locationDeserializerObserver = onLocationSearchChanged.subscribe((search) => { onDeserializedLocationSearchChanged.setValue(deserialize(search)) }) const popStateListener = (_ev: PopStateEvent): void => { updateState() } const hashChangeListener = (_ev: HashChangeEvent): void => { updateState() } window.addEventListener('popstate', popStateListener) window.addEventListener('hashchange', hashChangeListener) const originalPushState = window.history.pushState.bind(window.history) window.history.pushState = (...args: Parameters) => { originalPushState(...args) updateState() } const originalReplaceState = window.history.replaceState.bind(window.history) window.history.replaceState = (...args: Parameters) => { originalReplaceState(...args) updateState() } const navigate = (path: string): void => { // eslint-disable-next-line furystack/prefer-location-service -- This IS the LocationService.navigate() implementation. history.pushState(null, '', path) updateState() } const replace = (path: string): void => { // eslint-disable-next-line furystack/prefer-location-service -- This IS the LocationService.replace() implementation. history.replaceState(null, '', path) updateState() } const useSearchParam = (key: string, defaultValue: T): ObservableValue => { const existing = searchParamObservables.get(key) if (existing) return existing as ObservableValue const currentDeserialized = onDeserializedLocationSearchChanged.getValue() const actualValue = Object.prototype.hasOwnProperty.call(currentDeserialized, key) ? (currentDeserialized[key] as T) : defaultValue const newObservable = new ObservableValue(actualValue) searchParamObservables.set(key, newObservable) newObservable.subscribe((value) => { const currentQueryStringObject = onDeserializedLocationSearchChanged.getValue() if (currentQueryStringObject[key] !== value) { const params = serialize({ ...currentQueryStringObject, [key]: value }) const newUrl = `${location.pathname}?${params}` // eslint-disable-next-line furystack/prefer-location-service -- Internal LocationService plumbing for search param sync. history.pushState({}, '', newUrl) } }) onDeserializedLocationSearchChanged.subscribe((search) => { const value = (search[key] as T) ?? defaultValue searchParamObservables.get(key)?.setValue(value) }) return newObservable } onDispose(() => { window.removeEventListener('popstate', popStateListener) window.removeEventListener('hashchange', hashChangeListener) window.history.pushState = originalPushState window.history.replaceState = originalReplaceState locationDeserializerObserver[Symbol.dispose]() // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is deferred to the injector's onDispose hook. onLocationSearchChanged[Symbol.dispose]() // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is deferred to the injector's onDispose hook. onDeserializedLocationSearchChanged[Symbol.dispose]() // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is deferred to the injector's onDispose hook. onLocationPathChanged[Symbol.dispose]() // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is deferred to the injector's onDispose hook. onLocationHashChanged[Symbol.dispose]() }) return { deserializeQueryString: deserialize, onLocationPathChanged, onLocationHashChanged, onLocationSearchChanged, onDeserializedLocationSearchChanged, searchParamObservables, updateState, navigate, replace, useSearchParam, } }, }) /** * Configures custom (de)serialization for URL search state by binding * {@link LocationServiceSettings} on the given injector. * * Must be called **before** any consumer resolves {@link LocationService}: * the service patches `history.pushState` / `history.replaceState` and * subscribes to `popstate` / `hashchange` on construction, and those * listeners are only torn down when the owning injector is disposed. * Rebinding after the first resolution would leak the previous instance, * so this helper throws loudly if that happens. * * @param injector The root injector. * @param serialize Function to serialize state to a query string. * @param deserialize Function to deserialize a query string to state. * @throws If {@link LocationService} has already been resolved on * `injector` (or any reachable ancestor). */ export const useCustomSearchStateSerializer = ( injector: Injector, serialize: typeof defaultSerializeToQueryString, deserialize: typeof defaultDeserializeQueryString, ): void => { if (injector.isResolved(LocationService)) { throw new Error( 'useCustomSearchStateSerializer must be called before LocationService is resolved for the first time. ' + 'Configure serializers during injector bootstrap (e.g. before the first render).', ) } injector.bind(LocationServiceSettings, () => ({ serialize, deserialize })) injector.invalidate(LocationServiceSettings) }