@furystack/shades
Version:
A lightweight UI framework for FuryStack with JSX support
137 lines • 7.14 kB
JavaScript
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