Skip to content

Commit

Permalink
feat: add unstable useUrlHashState
Browse files Browse the repository at this point in the history
  • Loading branch information
SukkaW committed Oct 28, 2023
1 parent d5e12f9 commit 2d9ae53
Show file tree
Hide file tree
Showing 3 changed files with 159 additions and 55 deletions.
108 changes: 54 additions & 54 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
"gzip-size": "^6.0.0",
"next": "^13.4.9",
"react-router-dom": "^6.14.1",
"rollup": "^4.1.4",
"rollup": "^4.1.5",
"rollup-plugin-dts": "^6.1.0",
"rollup-plugin-swc3": "^0.10.3",
"rollup-swc-preserve-directives": "^0.3.0"
Expand Down
104 changes: 104 additions & 0 deletions src/use-url-hash-state/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import 'client-only';

import { useCallback, useSyncExternalStore } from 'react';
import { noop } from '../noop';
import { useStableHandler } from '../use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired';

const identity = <T>(x: string) => x as T;

const subscribe: Parameters<typeof useSyncExternalStore>[0] = (() => {
if (typeof window === 'undefined') {
return (_callback: () => void) => noop;
}

let hasSubscribedToHashChange = false;

const listeners = new Set<() => void>();

// call every listener when hash changes
const handleHashChange = () => {
listeners.forEach((listener) => listener());
};

// subscribe to hash change event by useSyncExternalStore
return (callback: () => void) => {
listeners.add(callback);

if (!hasSubscribedToHashChange) {
hasSubscribedToHashChange = true;
window.addEventListener('hashchange', handleHashChange);
}

return () => {
listeners.delete(callback);
};
};
})();

// This type utility is only used for workaround https://github.com/microsoft/TypeScript/issues/37663
// eslint-disable-next-line @typescript-eslint/ban-types -- workaround TypeScript bug
const isFunction = (x: unknown): x is Function => typeof x === 'function';

function useUrlHashState<T extends string | number>(
key: string,
defaultValue?: undefined
): readonly [T | undefined, React.Dispatch<React.SetStateAction<T | undefined>>];
function useUrlHashState<T extends string | number>(
key: string,
defaultValue: T,
transform?: (value: string) => T
): readonly [T, React.Dispatch<React.SetStateAction<T>>];
function useUrlHashState<T extends string | number>(
key: string,
defaultValue?: T | undefined,
transform: (value: string) => T = identity
): readonly [T | undefined, React.Dispatch<React.SetStateAction<T | undefined>>] {
const memoized_transform = useStableHandler(transform);

return [
useSyncExternalStore(
subscribe,
() => {
const searchParams = new URLSearchParams(location.hash.slice(1));
const storedValue = searchParams.get(key);
return storedValue !== null ? transform(storedValue) : defaultValue;
},
() => defaultValue
),
useCallback((updater) => {
const searchParams = new URLSearchParams(location.hash.slice(1));

const currentHash = location.hash;

let newValue;

if (isFunction(updater)) {
const storedValue = searchParams.get(key);
newValue = updater(storedValue !== null ? memoized_transform(storedValue) : defaultValue);
} else {
newValue = updater;
}

if (
(defaultValue !== undefined && newValue === defaultValue)
|| newValue === undefined
) {
searchParams.delete(key);
} else {
searchParams.set(key, JSON.stringify(newValue));
}

const newHash = searchParams.toString();

if (currentHash === newHash) {
return;
}

location.hash = searchParams.toString();
}, [defaultValue, key, memoized_transform])
] as const;
}

export {
useUrlHashState as unstable_useUrlHashState
};

0 comments on commit 2d9ae53

Please sign in to comment.