114 wiersze
3.4 KiB
TypeScript
114 wiersze
3.4 KiB
TypeScript
import React, { useContext, useEffect, useMemo, useState } from "react";
|
|
import { PageContext, PAGE_QUERY_ARG } from "./page";
|
|
|
|
export type ComponentWithShareableStateProps<T> = {
|
|
/** The default state to use when the component is first rendered. */
|
|
defaults: T;
|
|
|
|
/**
|
|
* Callback to trigger whenever the shareable state changes. Note
|
|
* that each call will add a new entry to the browser's navigation history.
|
|
* As such, every call to this function will mark a boundary where the
|
|
* user can press the "back" button to go back to the previous state.
|
|
*/
|
|
onChange: (value: T) => void;
|
|
};
|
|
|
|
export type PageWithShareableStateOptions<T> = {
|
|
/** The default shareable state. */
|
|
defaultValue: T;
|
|
|
|
/**
|
|
* Deserialize the given state, throwing an exception
|
|
* if it's invalid in any way.
|
|
*/
|
|
deserialize: (value: string) => T;
|
|
|
|
/** Serialize the given state to a string. */
|
|
serialize: (value: T) => string;
|
|
|
|
/** Component to render the page. */
|
|
component: React.ComponentType<ComponentWithShareableStateProps<T>>;
|
|
};
|
|
|
|
/** The query string argument that will store the serialized state. */
|
|
export const STATE_QUERY_ARG = "s";
|
|
|
|
/**
|
|
* Create a component that represents a page which exposes some
|
|
* aspect of its state in the current URL, so that it can be
|
|
* easily shared, recorded in the browser history, etc.
|
|
*/
|
|
export function createPageWithShareableState<T>({
|
|
defaultValue,
|
|
deserialize,
|
|
serialize,
|
|
component,
|
|
}: PageWithShareableStateOptions<T>): React.FC<{}> {
|
|
const Component = component;
|
|
|
|
const PageWithShareableState: React.FC<{}> = () => {
|
|
const { search, pushState, currPage } = useContext(PageContext);
|
|
|
|
/** The current serialized state, as reflected in the URL bar. */
|
|
const state = search.get(STATE_QUERY_ARG) || serialize(defaultValue);
|
|
|
|
/**
|
|
* What we think the latest serialized state is; used to determine whether
|
|
* the user navigated in their browser history.
|
|
*/
|
|
const [latestState, setLatestState] = useState(state);
|
|
|
|
/**
|
|
* The key to use when rendering our page component. This will
|
|
* be incremented whenever the user navigates their browser
|
|
* history, to ensure that our component resets itself to the
|
|
* default state we pass in.
|
|
*/
|
|
const [key, setKey] = useState(0);
|
|
|
|
/**
|
|
* Remembers whether we're in the middle of an update triggered by
|
|
* our own component.
|
|
*/
|
|
const [isInOnChange, setIsInOnChange] = useState(false);
|
|
|
|
/** The default state from th URL, which we'll pass into our component. */
|
|
let defaults: T = defaultValue;
|
|
|
|
try {
|
|
defaults = deserialize(state || "");
|
|
} catch (e) {
|
|
console.log(`Error deserializing state: ${e}`);
|
|
}
|
|
|
|
const onChange = useMemo(
|
|
() => (value: T) => {
|
|
const newState = serialize(value);
|
|
if (state !== newState) {
|
|
const newSearch = new URLSearchParams();
|
|
newSearch.set(PAGE_QUERY_ARG, currPage);
|
|
newSearch.set(STATE_QUERY_ARG, newState);
|
|
setIsInOnChange(true);
|
|
setLatestState(newState);
|
|
pushState("?" + newSearch.toString());
|
|
setIsInOnChange(false);
|
|
}
|
|
},
|
|
[state, currPage]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!isInOnChange && latestState !== state) {
|
|
// The user navigated in their browser.
|
|
setLatestState(state);
|
|
setKey(key + 1);
|
|
}
|
|
});
|
|
|
|
return <Component key={key} defaults={defaults} onChange={onChange} />;
|
|
};
|
|
|
|
return PageWithShareableState;
|
|
}
|