UNPKG

@furystack/shades

Version:

A lightweight UI framework for FuryStack with JSX support

137 lines 7.14 kB
import { defineService } from '@furystack/inject'; import { deserializeQueryString as defaultDeserializeQueryString, serializeToQueryString as defaultSerializeToQueryString, } from '@furystack/rest'; import { ObservableValue } from '@furystack/utils'; export const LocationServiceSettings = defineService({ name: '@furystack/shades/LocationServiceSettings', lifetime: 'singleton', factory: () => ({ serialize: defaultSerializeToQueryString, deserialize: defaultDeserializeQueryString, }), }); export const LocationService = 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 = () => { 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) => { updateState(); }; const hashChangeListener = (_ev) => { updateState(); }; window.addEventListener('popstate', popStateListener); window.addEventListener('hashchange', hashChangeListener); const originalPushState = window.history.pushState.bind(window.history); window.history.pushState = (...args) => { originalPushState(...args); updateState(); }; const originalReplaceState = window.history.replaceState.bind(window.history); window.history.replaceState = (...args) => { originalReplaceState(...args); updateState(); }; const navigate = (path) => { // eslint-disable-next-line furystack/prefer-location-service -- This IS the LocationService.navigate() implementation. history.pushState(null, '', path); updateState(); }; const replace = (path) => { // eslint-disable-next-line furystack/prefer-location-service -- This IS the LocationService.replace() implementation. history.replaceState(null, '', path); updateState(); }; const useSearchParam = (key, defaultValue) => { const existing = searchParamObservables.get(key); if (existing) return existing; const currentDeserialized = onDeserializedLocationSearchChanged.getValue(); const actualValue = Object.prototype.hasOwnProperty.call(currentDeserialized, key) ? currentDeserialized[key] : 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] ?? 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, serialize, deserialize) => { 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); }; //# sourceMappingURL=location-service.js.map