diff --git a/packages/params/README.md b/packages/params/README.md index d862968..bfdc876 100644 --- a/packages/params/README.md +++ b/packages/params/README.md @@ -173,45 +173,36 @@ If you have nested primitives like booleans and numbers, you can use `.into` to You may choose to use separate `$query` interceptors to make this simpler. -## Reactivity +## `observeHistory` -All normal reactive behaviors apply to the `$query` interceptor. You can hook up effects to them, and just have a grand old time. +You might want to use `$query` and have other tools that make changes to the query. By default, when `$query` intercepted values change, it is unaware of any other changes made to the `URL` and those change may be removed. -## What This Doesn't Do - -This plugin does not do anything to manage params not associated with an `$query` interceptor. This means that if you have a query string like `?search=hello&sort=asc` and you only have a `$query` interceptor for `search`, the `sort` param will be perpetuated during query string updates. +To handle this, you can import `observeHistory` and call it (with a `History` object, or it will default to `globalThis.history`), and the `pushState` and `replaceState` methods will be wrapped to update the reactive params when they are called. -This does not directly expose anything for triggering events or handlers on query string changes. As the query interceptors are reactive, you can hook directly into the ones you care about and use Alpine Effects to trigger events or other behaviors. - -## Use outside of Alpine +```js +import Alpine from 'alpinets/src'; +import { query, observeHistory } from '../src/index.ts'; +Alpine.plugin(query); +Alpine.data('test', () => ({ + count: Alpine.query(0).into(Number), +})); +observeHistory(); +Alpine.start(); -This Alpine plugin can actually be used outside of Alpine, though it's obviously not ideal for many reason. It's a bit of a hack, but it works! +history.pushState({}, '', '?count=123'); +``` -```ts -import { QueryInterceptor } from '@ekwoka/alpine-history'; +This is not needed to handle `popState` events which are already handled by the plugin. -const params: Record = {}; // internal object structure to store the params +## Reactivity -const myData = { - search: '', -}; +All normal reactive behaviors apply to the `$query` interceptor. You can hook up effects to them, and just have a grand old time. -new QueryInterceptor( - '', - { - raw: (v: T): T => { - v; - }, - }, - params, -) - .as('q') - .initialize(myData, 'search'); -``` +## What This Doesn't Do -Not example pretty, but it works! +This plugin does not do anything to manage params not associated with an `$query` interceptor. This means that if you have a query string like `?search=hello&sort=asc` and you only have a `$query` interceptor for `search`, the `sort` param will be perpetuated during query string updates. -This can allow you to use other reactive objects, like Solid Stores. But mostly, this is a hack, but fun! +This does not directly expose anything for triggering events or handlers on query string changes. As the query interceptors are reactive, you can hook directly into the ones you care about and use Alpine Effects to trigger events or other behaviors. ## Author diff --git a/packages/params/src/history.ts b/packages/params/src/history.ts new file mode 100644 index 0000000..b96ca34 --- /dev/null +++ b/packages/params/src/history.ts @@ -0,0 +1,35 @@ +type StateUpdateCallback = (url: URL) => void; + +const stateUpdateHandlers: StateUpdateCallback[] = []; + +export const onURLChange = (callback: StateUpdateCallback) => + stateUpdateHandlers.push(callback); + +let skip = false; +export const untrack = (cb: () => void) => { + skip = true; + cb(); + skip = false; +}; + +export const observeHistory = ( + injectHistory: Pick = history, +) => { + [UpdateMethod.replace, UpdateMethod.push].forEach((method) => { + const original = injectHistory[method]; + injectHistory[method] = ( + data: unknown, + title: string, + url?: string | null, + ) => { + original.call(injectHistory, data, title, url); + if (skip) return; + stateUpdateHandlers.forEach((handler) => handler(new URL(location.href))); + }; + }); +}; + +export enum UpdateMethod { + replace = 'replaceState', + push = 'pushState', +} diff --git a/packages/params/src/index.ts b/packages/params/src/index.ts index ef4717f..d4186d8 100644 --- a/packages/params/src/index.ts +++ b/packages/params/src/index.ts @@ -6,6 +6,7 @@ import { deleteDotNotatedValueFromData, insertDotNotatedValueIntoData, } from './pathresolve'; +import { UpdateMethod, onURLChange, untrack } from './history'; type InnerType = T extends PrimitivesToStrings ? T @@ -19,20 +20,18 @@ type InnerType = T extends PrimitivesToStrings * This hooks up setter/getter methods to to replace the object itself * and sync the query string params */ -export class QueryInterceptor< - T, - S extends Transformer | undefined = undefined, -> implements InterceptorObject> +class QueryInterceptor | undefined = undefined> + implements InterceptorObject> { _x_interceptor = true as const; private alias: string | undefined = undefined; private transformer?: S; - private method: 'replaceState' | 'pushState' = 'replaceState'; + private method: UpdateMethod = UpdateMethod.replace; private show: boolean = false; public initialValue: InnerType; constructor( initialValue: T, - private Alpine: Pick, + private Alpine: Pick, private reactiveParams: Record, ) { this.initialValue = initialValue as InnerType; @@ -46,10 +45,12 @@ export class QueryInterceptor< initialize(data: Record, path: string): InnerType { const { alias = path, + Alpine, initialValue, + method, reactiveParams, - transformer, show, + transformer, } = this; const initial = (retrieveDotNotatedValueFromData(alias, reactiveParams) ?? initialValue) as InnerType; @@ -63,7 +64,6 @@ export class QueryInterceptor< !show && value === initialValue ? deleteDotNotatedValueFromData(alias, reactiveParams) : insertDotNotatedValueIntoData(alias, value, reactiveParams); - this.setParams(); }, get: () => { const value = (retrieveDotNotatedValueFromData(alias, reactiveParams) ?? @@ -73,19 +73,10 @@ export class QueryInterceptor< enumerable: true, }); + Alpine.effect(paramEffect(alias, reactiveParams, method)); + return (transformer?.(initial) ?? initial) as InnerType; } - /** - * Sets the query string params to the current reactive params - */ - private setParams() { - const { reactiveParams, method, Alpine } = this; - history[method]( - intoState(Alpine.raw(reactiveParams)), - '', - `?${toQueryString(Alpine.raw(reactiveParams))}`, - ); - } /** * Changes the keyname for using in the query string * Keyname defaults to path to data @@ -115,7 +106,7 @@ export class QueryInterceptor< * Use pushState instead of replaceState */ usePush() { - this.method = 'pushState'; + this.method = UpdateMethod.push; return this; } } @@ -125,11 +116,20 @@ export const query: PluginCallback = (Alpine) => { fromQueryString(location.search), ); + const updateParams = (obj: Record) => { + Object.assign(reactiveParams, obj); + for (const key in Alpine.raw(reactiveParams)) + if (!(key in obj)) delete reactiveParams[key]; + }; + window.addEventListener('popstate', (event) => { if (!event.state?.query) return; - if (event.state.query) Object.assign(reactiveParams, event.state.query); - for (const key in Alpine.raw(reactiveParams)) - if (!(key in event.state.query)) delete reactiveParams[key]; + updateParams(event.state.query); + }); + + onURLChange((url) => { + const query = fromQueryString(url.search); + updateParams(query); }); const bindQuery = (initial: T) => @@ -166,18 +166,40 @@ const intoState = >( query: JSON.parse(JSON.stringify(data)), }); +const paramEffect = ( + key: string, + params: Record, + method: UpdateMethod, +) => { + let previous = JSON.stringify(params[key]); + return () => { + const current = JSON.stringify(params[key]); + if (current === previous) return; + untrack(() => setParams(params, method)); + previous = current; + }; +}; + +/** + * Sets the query string params to the current reactive params + */ +const setParams = (params: Record, method: UpdateMethod) => { + const queryString = toQueryString(params); + history[method]( + intoState(params), + '', + queryString ? `?${queryString}` : location.pathname, + ); +}; + if (import.meta.vitest) { - describe('QueryInterceptor', () => { - const Alpine = { - raw(val: T): T { - return val; - }, - }; + describe('QueryInterceptor', async () => { + const Alpine = await import('alpinejs').then((m) => m.default); afterEach(() => { vi.restoreAllMocks(); }); it('defines value on the data', () => { - const paramObject = {}; + const paramObject = Alpine.reactive({}); const data = { foo: 'bar' }; new QueryInterceptor('hello', Alpine, paramObject).initialize( data, @@ -186,7 +208,7 @@ if (import.meta.vitest) { expect(data).toEqual({ foo: 'hello' }); }); it('stores value in the params', () => { - const paramObject = {}; + const paramObject = Alpine.reactive({}); const interceptor = new QueryInterceptor('hello', Alpine, paramObject); const data = { foo: 'bar' }; interceptor.initialize(data, 'foo'); @@ -211,9 +233,9 @@ if (import.meta.vitest) { ).toBe('hello'); expect(data).toEqual({ foo: 'hello' }); }); - it('updates history state', () => { - vi.spyOn(history, 'replaceState'); - const paramObject = {}; + it('updates history state', async () => { + vi.spyOn(history, UpdateMethod.replace); + const paramObject = Alpine.reactive({}); const data = { foo: 'bar' }; new QueryInterceptor('hello', Alpine, paramObject).initialize( data, @@ -221,6 +243,7 @@ if (import.meta.vitest) { ); expect(data).toEqual({ foo: 'hello' }); data.foo = 'world'; + await Alpine.nextTick(); expect(paramObject).toEqual({ foo: 'world' }); expect(data).toEqual({ foo: 'world' }); expect(history.replaceState).toHaveBeenCalledWith( @@ -229,6 +252,7 @@ if (import.meta.vitest) { '?foo=world', ); data.foo = 'fizzbuzz'; + await Alpine.nextTick(); expect(paramObject).toEqual({ foo: 'fizzbuzz' }); expect(data).toEqual({ foo: 'fizzbuzz' }); expect(history.replaceState).toHaveBeenCalledWith( @@ -237,15 +261,16 @@ if (import.meta.vitest) { '?foo=fizzbuzz', ); }); - it('can alias the key', () => { - vi.spyOn(history, 'replaceState'); - const paramObject = {}; + it('can alias the key', async () => { + vi.spyOn(history, UpdateMethod.replace); + const paramObject = Alpine.reactive({}); const data = { foo: 'bar' }; new QueryInterceptor('hello', Alpine, paramObject) .as('bar') .initialize(data, 'foo'); expect(data).toEqual({ foo: 'hello' }); data.foo = 'world'; + await Alpine.nextTick(); expect(paramObject).toEqual({ bar: 'world' }); expect(data).toEqual({ foo: 'world' }); expect(history.replaceState).toHaveBeenCalledWith( @@ -263,30 +288,33 @@ if (import.meta.vitest) { expect(data).toEqual({ count: 1 }); expect(paramObject).toEqual({ count: 1 }); }); - it('does not display inital value', () => { - vi.spyOn(history, 'replaceState'); - const paramObject = {}; + it('does not display inital value', async () => { + vi.spyOn(history, UpdateMethod.replace); + const paramObject = Alpine.reactive({}); const data = { foo: 'bar' }; new QueryInterceptor(data.foo, Alpine, paramObject).initialize( data, 'foo', ); data.foo = 'hello'; + await Alpine.nextTick(); expect(data).toEqual({ foo: 'hello' }); expect(paramObject).toEqual({ foo: 'hello' }); data.foo = 'bar'; + await Alpine.nextTick(); expect(data).toEqual({ foo: 'bar' }); expect(paramObject).toEqual({}); - expect(history.replaceState).toHaveBeenCalledWith({ query: {} }, '', '?'); + expect(history.replaceState).toHaveBeenCalledWith({ query: {} }, '', '/'); }); - it('can always show the initial value', () => { - vi.spyOn(history, 'replaceState'); - const paramObject = {}; + it('can always show the initial value', async () => { + vi.spyOn(history, UpdateMethod.replace); + const paramObject = Alpine.reactive({}); const data = { foo: 'bar' }; new QueryInterceptor(data.foo, Alpine, paramObject) .alwaysShow() .initialize(data, 'foo'); data.foo = 'hello'; + await Alpine.nextTick(); expect(data).toEqual({ foo: 'hello' }); expect(paramObject).toEqual({ foo: 'hello' }); expect(history.replaceState).toHaveBeenCalledWith( @@ -295,6 +323,7 @@ if (import.meta.vitest) { '?foo=hello', ); data.foo = 'bar'; + await Alpine.nextTick(); expect(data).toEqual({ foo: 'bar' }); expect(paramObject).toEqual({ foo: 'bar' }); expect(history.replaceState).toHaveBeenCalledWith( @@ -303,15 +332,16 @@ if (import.meta.vitest) { '?foo=bar', ); }); - it('can use pushState', () => { - vi.spyOn(history, 'replaceState'); - vi.spyOn(history, 'pushState'); - const paramObject = {}; + it('can use pushState', async () => { + vi.spyOn(history, UpdateMethod.replace); + vi.spyOn(history, UpdateMethod.push); + const paramObject = Alpine.reactive({}); const data = { foo: 'bar' }; new QueryInterceptor(data.foo, Alpine, paramObject) .usePush() .initialize(data, 'foo'); data.foo = 'hello'; + await Alpine.nextTick(); expect(data).toEqual({ foo: 'hello' }); expect(paramObject).toEqual({ foo: 'hello' }); expect(history.pushState).toHaveBeenCalledWith( @@ -333,3 +363,5 @@ type PrimitivesToStrings = T extends string | number | boolean | null [K in keyof T]: PrimitivesToStrings; } : T; + +export { observeHistory } from './history'; diff --git a/packages/params/testSite/index.html b/packages/params/testSite/index.html index 01b4fa2..22f9e5b 100644 --- a/packages/params/testSite/index.html +++ b/packages/params/testSite/index.html @@ -6,17 +6,17 @@ X RIAS TEST SITE
-
diff --git a/size.json b/size.json index a8b17a9..983d322 100644 --- a/size.json +++ b/size.json @@ -1,12 +1,12 @@ { "params": { "minified": { - "pretty": "1.88 kB", - "raw": 1877 + "pretty": "2.18 kB", + "raw": 2178 }, "brotli": { - "pretty": "897 B", - "raw": 897 + "pretty": "1.03 kB", + "raw": 1029 } }, "xajax": {