UNPKG

@svelte-navigator/history

Version:
429 lines (419 loc) 18.5 kB
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