From 4595cec61b939c9684554f1ba2cb71ae92cff710 Mon Sep 17 00:00:00 2001 From: jacob-8 Date: Mon, 18 Mar 2024 10:55:44 +0800 Subject: [PATCH] fix: createQueryParamStore always uses goto with `keepFocus` in addition to `noScroll` so it works again in SvelteKit 2, also update function to make it match writable and adds optional storagePrefix option --- src/lib/stores/query-param-store.ts | 141 ++++++++++++++-------------- src/lib/stores/url-helpers.ts | 98 ------------------- 2 files changed, 70 insertions(+), 169 deletions(-) delete mode 100644 src/lib/stores/url-helpers.ts diff --git a/src/lib/stores/query-param-store.ts b/src/lib/stores/query-param-store.ts index 9499604..4c08265 100644 --- a/src/lib/stores/query-param-store.ts +++ b/src/lib/stores/query-param-store.ts @@ -1,119 +1,118 @@ -import { writable, type Readable } from 'svelte/store'; +import { writable, type Writable } from 'svelte/store'; import { goto } from '$app/navigation'; import { page } from '$app/stores'; -import { decodeParam, encodeParam } from './url-helpers'; -export interface QueryParamStore extends Readable { - set: (value: any) => void; +export interface QueryParamStore extends Writable { remove: () => void; } export interface QueryParamStoreOptions { key: string; - replaceState?: boolean; startWith?: T; - log?: boolean; + replaceState?: boolean; persist?: 'localStorage' | 'sessionStorage'; + storagePrefix?: string; + log?: boolean; } -export function createQueryParamStore( - opts: QueryParamStoreOptions = { - key: 'queryParam', - replaceState: true, +const stringify = (value) => { + if (typeof value === 'undefined' || value === null) return undefined; + if (typeof value === 'string') return value; + return JSON.stringify(value); +}; + +const parse = (value: string) => { + if (typeof value === 'undefined') return undefined; + try { + return JSON.parse(value); + } catch { + return value; // if the original input was just a string (and never JSON stringified), it will throw an error so just return the string + } +}; + +export function createQueryParamStore(opts: QueryParamStoreOptions) { + const { key, log, persist } = opts; + const replaceState = typeof opts.replaceState === 'undefined' ? true : opts.replaceState; + const storageKey = `${opts.storagePrefix || ''}${key}` + + let storage: Storage = undefined + if (typeof window !== 'undefined') { + if (persist === 'localStorage') + storage = localStorage; + if (persist === 'sessionStorage') + storage = sessionStorage; } -): QueryParamStore { - const { key, startWith, log, replaceState, persist } = opts; - const updateQueryParam = (value: any) => { + const setQueryParam = (value) => { if (typeof window === 'undefined') return; // safety check in case store value is assigned via $: call server side if (value === undefined || value === null) return removeQueryParam(); - // from https://github.com/sveltejs/kit/issues/969 - const url = new URL(window.location.href); - url.searchParams.set(key, encodeParam(value)); - - if (replaceState) { - history.replaceState({}, '', url); - setStoreValue(value); - } else { - goto(url.toString(), { noScroll: true }); // breaks input focus - } - - log && console.log(`user action changed: ${key} to ${value}`); + const {hash} = window.location + const searchParams = new URLSearchParams(window.location.search) + searchParams.set(key, stringify(value)); + goto(`?${searchParams}${hash}`, { keepFocus: true, noScroll: true, replaceState }); + if (log) console.info(`user action changed: ${key} to ${value}`); }; - const removeQueryParam = () => { - const url = new URL(window.location.href); - url.searchParams.delete(key); - - if (replaceState) { - history.replaceState({}, '', url); - setStoreValue(null); - } else { - goto(url.toString(), { noScroll: true }); // breaks input focus - } + const updateQueryParam = (fn: (value: T) => T) => { + const searchParams = new URLSearchParams(window.location.search) + const value = searchParams.get(key); + const parsed_value = parse(value) as T; + setQueryParam(fn(parsed_value)); + } - log && console.log(`user action removed: ${key}`); + const removeQueryParam = () => { + const {hash} = window.location + const searchParams = new URLSearchParams(window.location.search) + searchParams.delete(key); + goto(`?${searchParams}${hash}`, { keepFocus: true, noScroll: true, replaceState }); + if (log) console.info(`user action removed: ${key}`); }; const setStoreValue = (value: string) => { - const properlyTypedValue = decodeParam(value) as T; - typeof window !== 'undefined' && localStorage.setItem(key, JSON.stringify(properlyTypedValue)); - log && console.log(`URL set ${key} to ${properlyTypedValue}`); - set(properlyTypedValue); + const parsed_value = parse(value) as T; + set(parsed_value); + if (log) console.info(`URL set ${key} to ${parsed_value}`); + storage?.setItem(storageKey, JSON.stringify(parsed_value)); + if (log && storage) console.info({[storageKey + '_to_cache']: parsed_value}); }; let firstUrlCheck = true; const start = () => { - const _teardown = page.subscribe(({ url: { searchParams } }) => { + const unsubscribe_from_page_store = page.subscribe(({ url: { searchParams } }) => { let value = searchParams.get(key); - // Subsequent URL changes + // Set store value from url - skipped on first load if (!firstUrlCheck) return setStoreValue(value); firstUrlCheck = false; - // URL load - // 1st Priority: query param - // @ts-ignore + // 1st Priority: check url query param for value if (value !== undefined && value !== null && value !== '') return setStoreValue(value); - // 2nd Priority: local/sessionStorage if (typeof window === 'undefined') return; - if (persist === 'localStorage') { - value = JSON.parse(localStorage.getItem(key)); - log && console.log(`cached: ${key} is ${value}`); - } - if (persist === 'sessionStorage') { - value = JSON.parse(sessionStorage.getItem(key)); - log && console.log(`cached: ${key} is ${value}`); + + // 2nd Priority: check localStorage/sessionStorage for value + if (persist) { + value = JSON.parse(storage.getItem(storageKey)); + if (log) console.info({[storageKey + '_from_cache']: value}); } - if (value) return updateQueryParam(value); + + if (value) return setQueryParam(value); }); - // Unsubscribes from page store when query param store is no longer in use - return () => _teardown(); + return () => unsubscribe_from_page_store(); }; - // 3rd Priority: startWith won't be overridden if query param nor local/sessionStorage key is set - const store = writable(startWith, start); + // 3rd Priority: use startWith if no query param in url nor storage value found + const store = writable(opts.startWith, start); const { subscribe, set } = store; return { subscribe, - set: updateQueryParam, + set: setQueryParam, + update: updateQueryParam, remove: removeQueryParam, }; } -// const newValues = {} -// for (const key of page.url.searchParams.keys()) { -// console.log(page.url.searchParams.get(key)); -// newValues[key] = page.url.searchParams.get(key); -// set(newValues) -// } - -// window.addEventListener('popstate', (e) => { -// console.log(e); -// const { searchParams } = new URL(window.location.href); -// console.log(`${searchParams.get(key)}, ${e.state}`); -// }); +// SvelteKit Goto dicussion https://github.com/sveltejs/kit/issues/969 diff --git a/src/lib/stores/url-helpers.ts b/src/lib/stores/url-helpers.ts deleted file mode 100644 index b2748fb..0000000 --- a/src/lib/stores/url-helpers.ts +++ /dev/null @@ -1,98 +0,0 @@ -export const encodeParam = (value: any) => { - if (typeof value === 'undefined' || value === null) return undefined; - if (typeof value === 'string') return encodeURIComponent(value); - return encodeURIComponent(JSON.stringify(value)); -}; - -export const decodeParam = (value: string) => { - if (typeof value === 'undefined') return undefined; - const decoded = decodeURIComponent(value); - try { - return JSON.parse(decoded); - } catch { - // if a decoded string that was never JSON stringified (to save URL space) is parsed, it will throw an error and so we don't parse it but just return the string - return decoded; - } -}; - -export const encode = (value: string | string[][] | Record | URLSearchParams) => { - return new URLSearchParams(value).toString(); -}; - -const object = { - foo: 'hi there', - bar: { - blah: 123, - quux: [1, 2, 3], - }, -}; - -if (import.meta.vitest) { - test('encodeParam handles undefined, null, string, number, boolean, object, and array with an object inside', () => { - expect(encodeParam(undefined)).toMatchInlineSnapshot('undefined'); - expect(encodeParam(null)).toMatchInlineSnapshot('undefined'); - expect(encodeParam('Hello')).toMatchInlineSnapshot('"Hello"'); - expect(encodeParam('Hello world? & some = that')).toMatchInlineSnapshot( - '"Hello%20world%3F%20%26%20some%20%3D%20that"' - ); - expect(encodeParam(45)).toMatchInlineSnapshot('"45"'); - expect(encodeParam(true)).toMatchInlineSnapshot('"true"'); - expect(encodeParam(object)).toMatchInlineSnapshot( - '"%7B%22foo%22%3A%22hi%20there%22%2C%22bar%22%3A%7B%22blah%22%3A123%2C%22quux%22%3A%5B1%2C2%2C3%5D%7D%7D"' - ); - - expect(encodeParam([1, 2, object])).toMatchInlineSnapshot( - '"%5B1%2C2%2C%7B%22foo%22%3A%22hi%20there%22%2C%22bar%22%3A%7B%22blah%22%3A123%2C%22quux%22%3A%5B1%2C2%2C3%5D%7D%7D%5D"' - ); - }); - - test('decodeParam', () => { - expect(decodeParam(undefined)).toEqual(undefined); - expect(decodeParam(null)).toEqual(null); - expect(decodeParam('Hello')).toMatchInlineSnapshot('"Hello"'); - expect(decodeParam('Hello%20world%3F%20%26%20some%20%3D%20that')).toMatchInlineSnapshot( - '"Hello world? & some = that"' - ); - expect(decodeParam('45')).toEqual(45); - expect(decodeParam('true')).toEqual(true); - expect(decodeParam('false')).toEqual(false); - expect( - decodeParam( - '%7B%22foo%22%3A%22hi%20there%22%2C%22bar%22%3A%7B%22blah%22%3A123%2C%22quux%22%3A%5B1%2C2%2C3%5D%7D%7D' - ) - ).toMatchInlineSnapshot(` - { - "bar": { - "blah": 123, - "quux": [ - 1, - 2, - 3, - ], - }, - "foo": "hi there", - } - `); - expect( - decodeParam( - '%5B1%2C2%2C%7B%22foo%22%3A%22hi%20there%22%2C%22bar%22%3A%7B%22blah%22%3A123%2C%22quux%22%3A%5B1%2C2%2C3%5D%7D%7D%5D' - ) - ).toMatchInlineSnapshot(` - [ - 1, - 2, - { - "bar": { - "blah": 123, - "quux": [ - 1, - 2, - 3, - ], - }, - "foo": "hi there", - }, - ] - `); - }); -}