@svelte-navigator/history
Version:
History module for svelte-navigator
429 lines (419 loc) • 18.5 kB
JavaScript
const NAVIGATE_TO_TYPE = 0;
const NAVIGATE_GO_TO_IS_INT = 1;
const NAVIGATE_STARTS_WITH_SLASH = 2;
const PARSE_PATH_TYPE = 3;
const PARSE_PATH_STARTS_WITH_SLASH = 4;
const STRINGIFY_PATH_TYPE = 5;
const HISTORY_PUSH_URI_IS_STRING = 6;
const HISTORY_PUSH_URI_STARTS_WITH_SLASH = 7;
const HISTORY_REPLACE_URI_IS_STRING = 8;
const HISTORY_REPLACE_URI_STARTS_WITH_SLASH = 9;
const HISTORY_GO_DELTA_IS_NUM = 10;
const HISTORY_GO_DELTA_IS_INT = 11;
const BROWSER_HISTORY_NEEDS_DOCUMENT = 12;
const HASH_HISTORY_NEEDS_DOCUMENT = 13;
const errorMessages = {
[NAVIGATE_TO_TYPE]: "`<navigate>` First argument is expected to be a string or a number.\n" +
"`history.navigate` was called with an incorrect type. Make sure to " +
"only call it with values of type string or number.",
[NAVIGATE_GO_TO_IS_INT]: "`<navigate>` When supplying a number, the first argument is expected to " +
"be a whole number.",
[NAVIGATE_STARTS_WITH_SLASH]: '`<navigate>` First argument must start with "/".\n' +
"Calling `history.navigate` with relative paths is not supported. Make " +
"sure to call it with absolute paths only." +
"Search/hash only navigation is also not supported.",
[PARSE_PATH_TYPE]: "`<parsePath>` First argument is expected to be a string.",
[PARSE_PATH_STARTS_WITH_SLASH]: '`<parsePath>` First argument must start with "/", "?" or "#".\n' +
"`parsePath` can only parse absolute paths. For search or hash only " +
'paths a pathname of `"/"` will be asumed.',
[STRINGIFY_PATH_TYPE]: "`<stringifyPath>` First argument is expected to be a location object " +
"with `pathname`, `search` and `hash` properties of type string.\n" +
"`stringifyPath` can only join full location objects to path strings. " +
'Sample location objects: `{ pathname: "/about", search: "", hash: "" }`, ' +
'`{ pathname: "/search", search: "?q=falafel", hash: "#result-3" }`',
[HISTORY_PUSH_URI_IS_STRING]: "`<History.push>` First argument is expected to be a string.",
[HISTORY_PUSH_URI_STARTS_WITH_SLASH]: '`<History.push>` First argument must start with "/".\n' +
"Calling `history.push` with relative paths is not supported. Make " +
"sure to call it with absolute paths only." +
"Search/hash only navigation is also not supported.",
[HISTORY_REPLACE_URI_IS_STRING]: "`<History.replace>` First argument is expected to be a string.",
[HISTORY_REPLACE_URI_STARTS_WITH_SLASH]: '`<History.replace>` First argument must start with "/".\n' +
"Calling `history.replace` with relative paths is not supported. Make " +
"sure to call it with absolute paths only." +
"Search/hash only navigation is also not supported.",
[HISTORY_GO_DELTA_IS_NUM]: "`<History.go>` First argument is expected to be a number.",
[HISTORY_GO_DELTA_IS_INT]: "`<History.go>` First argument is expected to be a whole number.",
[BROWSER_HISTORY_NEEDS_DOCUMENT]: "`<createBrowserHistory>` The browser history can only be used in a " +
"browser environment, but no `document` object could be found. " +
"Are you trying to create the history in a test environment? If " +
"so, consider using a different history, such as the memory or auto " +
"history or emulate the browser environment with a library, such as " +
"jsdom. If you're using the history in an SSR context, consider " +
"using the auto history.",
[HASH_HISTORY_NEEDS_DOCUMENT]: "`<createHashHistory>` The hash history can only be used in a " +
"browser environment, but no `document` object could be found. " +
"Are you trying to create the history in a test environment? If " +
"so, consider using a different history, such as the memory or auto " +
"history or emulate the browser environment with a library, such as " +
"jsdom. If you're using the history in an SSR context, consider " +
"using the auto history.",
};
const invariant = (conditionFails, errorId, actual) => {
if (conditionFails) {
{
let message = errorMessages[errorId];
if (actual) {
message += ` Got "${actual}"`;
}
throw new Error(message);
}
}
};
const substr = (str, start, end) => str.substr(start, end);
const startsWith = (str, start) => substr(str, 0, 1) === start;
/**
* Create a globally unique id
*
* @returns An id
*/
const createGlobalId = () => substr(Math.random().toString(36), 2);
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => { };
const isString = (maybeStr) => typeof maybeStr === "string";
const isNumber = (maybeNum) => typeof maybeNum === "number";
const normalizeUrlFragment = (frag) => frag.length === 1 ? "" : frag;
/**
* Creates a location object from an url.
* It is used to create a location from the url prop used in SSR
*
* @param {string} url The url string (e.g. "/path/to/somewhere")
* @returns {HistoryLocation} The location
*
* @example
* ```js
* const path = "/search?q=falafel#result-3";
* const location = parsePath(path);
* // -> {
* // pathname: "/search",
* // search: "?q=falafel",
* // hash: "#result-3",
* // };
* ```
*/
const parsePath = (path) => {
invariant(!isString(path), PARSE_PATH_TYPE, typeof path);
invariant(path !== "" &&
!startsWith(path, "/") &&
!startsWith(path, "?") &&
!startsWith(path, "#"), PARSE_PATH_STARTS_WITH_SLASH, path);
const searchIndex = path.indexOf("?");
const hashIndex = path.indexOf("#");
const hasSearchIndex = searchIndex !== -1;
const hasHashIndex = hashIndex !== -1;
const hash = hasHashIndex
? normalizeUrlFragment(substr(path, hashIndex))
: "";
const pathnameAndSearch = hasHashIndex ? substr(path, 0, hashIndex) : path;
const search = hasSearchIndex
? normalizeUrlFragment(substr(pathnameAndSearch, searchIndex))
: "";
const pathname = (hasSearchIndex
? substr(pathnameAndSearch, 0, searchIndex)
: pathnameAndSearch) || "/";
return { pathname, search, hash };
};
/**
* Joins a location object to one path string.
*
* @param {HistoryLocation} location The location object
* @returns {string} A path, created from the location
*
* @example
* ```js
* const location = {
* pathname: "/search",
* search: "?q=falafel",
* hash: "#result-3",
* };
* const path = stringifyPath(location);
* // -> "/search?q=falafel#result-3"
* ```
*/
const stringifyPath = (location) => {
const { pathname, search, hash } = location;
invariant(!isString(pathname) || !isString(search) || !isString(hash), STRINGIFY_PATH_TYPE, JSON.stringify(location));
return pathname + search + hash;
};
const getPathString = (to) => isString(to) ? to : stringifyPath(to);
const getPathObject = (to) => isString(to) ? parsePath(to) : to;
const addListener = (target, type, handler) => {
target.addEventListener(type, handler);
return () => target.removeEventListener(type, handler);
};
const POP = "POP";
const PUSH = "PUSH";
const REPLACE = "REPLACE";
const assertPushReplaceArgs = (uri, isStringId, startsWithSlashId) => {
invariant(!isString(uri), isStringId, typeof uri);
invariant(!startsWith(uri, "/"), startsWithSlashId, uri);
};
const assertGoArgs = (delta, deltaIsNumId, deltaIsIntId) => {
invariant(!isNumber(delta), deltaIsNumId, typeof delta);
invariant(
// `number | 0` => short variant of `Math.floor(number)`
delta !== (delta | 0), // eslint-disable-line no-bitwise
deltaIsIntId, delta);
};
/**
* Create the navigate helper function needed in a `NavigatorHistory`.
*/
function createNavigate({ go, push, replace: replaceState, }) {
const navigate = (to, options) => {
const { state = null, replace = false } = options || {};
if (isNumber(to)) {
assertGoArgs(to, NAVIGATE_TO_TYPE, NAVIGATE_GO_TO_IS_INT);
if ( options) {
// eslint-disable-next-line no-console
console.warn("<navigate> Navigation options (state or replace) are not " +
"supported, when passing a number as the first argument to " +
"navigate. They are ignored.");
}
go(to);
}
else {
assertPushReplaceArgs(to, NAVIGATE_TO_TYPE, NAVIGATE_STARTS_WITH_SLASH);
(replace ? replaceState : push)(to, state);
}
};
return navigate;
}
const getBrowserStateAndKey = (history) => {
const { state } = history;
return {
state: (state && state.value) || null,
key: (state && state.key) || "initial",
};
};
const createState = (state) => ({
value: state === undefined ? null : state,
key: createGlobalId(),
});
function createSubscribable(getState = noop) {
const subscribers = new Set();
return {
subscribe(subscriber) {
subscribers.add(subscriber);
// Call subscriber when it is registered
subscriber(getState());
return () => {
subscribers.delete(subscriber);
};
},
notify: () => subscribers.forEach((subscriber) => subscriber(getState())),
};
}
function createHistoryContainer(initialLocation) {
let location = initialLocation;
let action = POP;
const { subscribe, notify } = createSubscribable(() => ({
location,
action,
}));
return {
getLocation() {
return location;
},
getAction() {
return action;
},
set(nextLocation, nextAction) {
location = nextLocation;
action = nextAction;
notify();
},
subscribe,
};
}
const HistoryType = Symbol("historyType");
/**
* Use the HTML5 History API to store app loaction and state in the url.
* This is probably the best choice for most apps, as it enables best ceo
* possibilities and is probably most intuitive.
* This setup however needs some additional work because you need to
* configure your server to always serve your index.html file when a
* request doesn't match a file.
* You can read more about it in [vue-routers docs about history routing](
* https://router.vuejs.org/guide/essentials/history-mode.html#example-server-configurations).
*
* @param {BrowserHistoryOptions} [options] Supply a custom window object
*
* @returns {BrowserHistory} The history object
*/
function createBrowserHistory({ window: windowArg, } = {}) {
invariant(typeof document === "undefined", BROWSER_HISTORY_NEEDS_DOCUMENT);
const window = windowArg || document.defaultView;
const { history, location } = window;
const getBrowserLocation = () => {
const { pathname, search, hash } = location;
return Object.assign({ pathname: encodeURI(decodeURI(pathname)), search,
hash }, getBrowserStateAndKey(history));
};
const { subscribe, set: setState, getAction, getLocation, } = createHistoryContainer(getBrowserLocation());
const popstateUnlisten = addListener(window, "popstate", () => setState(getBrowserLocation(), POP));
const actions = {
push(uri, state) {
assertPushReplaceArgs(uri, HISTORY_PUSH_URI_IS_STRING, HISTORY_PUSH_URI_STARTS_WITH_SLASH);
// try...catch iOS Safari limits to 100 pushState calls
try {
history.pushState(createState(state), "", uri);
}
catch (e) {
location.assign(uri);
}
setState(getBrowserLocation(), PUSH);
},
replace(uri, state) {
assertPushReplaceArgs(uri, HISTORY_REPLACE_URI_IS_STRING, HISTORY_REPLACE_URI_STARTS_WITH_SLASH);
history.replaceState(createState(state), "", uri);
setState(getBrowserLocation(), REPLACE);
},
go(delta) {
assertGoArgs(delta, HISTORY_GO_DELTA_IS_NUM, HISTORY_GO_DELTA_IS_INT);
history.go(delta);
},
};
return Object.assign(Object.assign({ get location() {
return getLocation();
},
get action() {
return getAction();
},
subscribe, createHref: getPathString, navigate: createNavigate(actions), release: popstateUnlisten }, actions), { [HistoryType]: "browser" });
}
/**
* Use the hash fragment of the url to simulate routing via url.
* This approach is great if you want to get started quickly or if
* you don't have access to your server's configuration.
*
* @param {HashHistoryOptions} [options] Supply a custom window object
*
* @returns {HashHistory} The history object
*/
function createHashHistory({ window: windowArg, } = {}) {
invariant(typeof document === "undefined", HASH_HISTORY_NEEDS_DOCUMENT);
const window = windowArg || document.defaultView;
const { history, location } = window;
const getHashPath = () => substr(location.hash, 1);
const getHashLocation = () => (Object.assign(Object.assign({}, parsePath(getHashPath())), getBrowserStateAndKey(history)));
const { subscribe, set: setState, getAction, getLocation, } = createHistoryContainer(getHashLocation());
const popstateListener = () => setState(getHashLocation(), POP);
const popstateUnlisten = addListener(window, "popstate", popstateListener);
const hashchangeUnlisten = addListener(window, "hashchange", () => {
// Ignore extraneous hashchange events.
if (getHashPath() !== stringifyPath(getLocation())) {
popstateListener();
}
});
const createHref = (to) => `#${getPathString(to)}`;
const actions = {
push(uri, state) {
assertPushReplaceArgs(uri, HISTORY_PUSH_URI_IS_STRING, HISTORY_PUSH_URI_STARTS_WITH_SLASH);
const fullUri = createHref(uri);
// try...catch iOS Safari limits to 100 pushState calls
try {
history.pushState(createState(state), "", fullUri);
}
catch (e) {
location.assign(fullUri);
}
setState(getHashLocation(), PUSH);
},
replace(uri, state) {
assertPushReplaceArgs(uri, HISTORY_REPLACE_URI_IS_STRING, HISTORY_REPLACE_URI_STARTS_WITH_SLASH);
history.replaceState(createState(state), "", createHref(uri));
setState(getHashLocation(), REPLACE);
},
go(delta) {
assertGoArgs(delta, HISTORY_GO_DELTA_IS_NUM, HISTORY_GO_DELTA_IS_INT);
history.go(delta);
},
};
return Object.assign(Object.assign({ get location() {
return getLocation();
},
get action() {
return getAction();
},
subscribe,
createHref, navigate: createNavigate(actions), release() {
popstateUnlisten();
hashchangeUnlisten();
} }, actions), { [HistoryType]: "hash" });
}
const createStackFrame = (to, state) => {
const { value, key } = createState(state);
return Object.assign({ state: value, key }, getPathObject(to));
};
/**
* Keep the location state of your app in memory.
* This is mainly useful for testing if you don't run your tests
* in a browser.
* You could also use a memory history if you want to controll the
* state of a widget you embed in another app using a router.
*
* @param {MemoryHistoryOptions} [options] Supply initial entries for
* the history stack
*
* @returns {MemoryHistory} The history object
*/
function createMemoryHistory(options) {
const initialEntries = isString(options)
? [options]
: (options && options.initialEntries) || ["/"];
const initialIndex = isString(options)
? 0
: (options && options.initialIndex) || 0;
let index = initialIndex;
let stack = initialEntries.map((to) => createStackFrame(to, null));
const getMemoryLocation = () => stack[index];
const { subscribe, set: setState, getAction, getLocation, } = createHistoryContainer(getMemoryLocation());
const actions = {
push(uri, state) {
assertPushReplaceArgs(uri, HISTORY_PUSH_URI_IS_STRING, HISTORY_PUSH_URI_STARTS_WITH_SLASH);
// eslint-disable-next-line no-plusplus
index++;
// Throw away anything in the stack with an index greater than the current index.
// This happens, when we go back using `go(-n)`. The index is now less than `stack.length`.
// If we call `go(+n)` the stack entries with an index greater than the current index can
// be reused.
// However, if we navigate to a path, instead of a number, we want to create a new branch
// of navigation.
stack = stack.slice(0, index);
stack.push(createStackFrame(uri, state));
setState(getMemoryLocation(), PUSH);
},
replace(uri, state) {
assertPushReplaceArgs(uri, HISTORY_REPLACE_URI_IS_STRING, HISTORY_REPLACE_URI_STARTS_WITH_SLASH);
stack[index] = createStackFrame(uri, state);
setState(getMemoryLocation(), REPLACE);
},
go(delta) {
assertGoArgs(delta, HISTORY_GO_DELTA_IS_NUM, HISTORY_GO_DELTA_IS_INT);
const newIndex = index + delta;
if (newIndex < 0 || newIndex > stack.length - 1) {
return;
}
index = newIndex;
setState(getMemoryLocation(), POP);
},
};
return Object.assign(Object.assign({ get location() {
return getLocation();
},
get action() {
return getAction();
},
subscribe, createHref: getPathString, navigate: createNavigate(actions), release: noop }, actions), { [HistoryType]: "memory" });
}
export { HistoryType, createBrowserHistory, createHashHistory, createMemoryHistory, createNavigate, parsePath, stringifyPath };
//# sourceMappingURL=history.development.mjs.map