From a4b7c1872b4d38d6e78010a1c28c653703b16fec Mon Sep 17 00:00:00 2001 From: Eric Kwoka <43540491+ekwoka@users.noreply.github.com> Date: Sun, 26 Nov 2023 21:01:45 +0400 Subject: [PATCH 1/4] :bug: Fixes nested Reactivity --- packages/params/src/index.ts | 108 ++++++++++++++++------------ packages/params/testSite/index.html | 13 ++-- size.json | 8 +-- 3 files changed, 76 insertions(+), 53 deletions(-) diff --git a/packages/params/src/index.ts b/packages/params/src/index.ts index ef4717f..f7d39fe 100644 --- a/packages/params/src/index.ts +++ b/packages/params/src/index.ts @@ -19,20 +19,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 +44,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 +63,6 @@ export class QueryInterceptor< !show && value === initialValue ? deleteDotNotatedValueFromData(alias, reactiveParams) : insertDotNotatedValueIntoData(alias, value, reactiveParams); - this.setParams(); }, get: () => { const value = (retrieveDotNotatedValueFromData(alias, reactiveParams) ?? @@ -73,19 +72,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 +105,7 @@ export class QueryInterceptor< * Use pushState instead of replaceState */ usePush() { - this.method = 'pushState'; + this.method = UpdateMethod.push; return this; } } @@ -166,18 +156,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; + setParams(params, method); + previous = current; + }; +}; + +/** + * Sets the query string params to the current reactive params + */ +const setParams = (params: Record, method: UpdateMethod) => { + history[method](intoState(params), '', `?${toQueryString(params)}`); +}; + +enum UpdateMethod { + replace = 'replaceState', + push = 'pushState', +} + 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 +198,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 +223,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 +233,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 +242,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 +251,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 +278,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: {} }, '', '?'); }); - 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 +313,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 +322,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( diff --git a/packages/params/testSite/index.html b/packages/params/testSite/index.html index 01b4fa2..59ef49e 100644 --- a/packages/params/testSite/index.html +++ b/packages/params/testSite/index.html @@ -9,14 +9,17 @@ import query from '../src/index.ts'; Alpine.plugin(query); Alpine.data('test', () => ({ - count: Alpine.query(0).into(Number), - date: Alpine.query(new Date().toString()).as('now').alwaysShow(), + foo: Alpine.query({ + fizz: 'buzz', + bar: [{ first: 'second', count: 0 }], + }).alwaysShow(), })); Alpine.start(); + window.Alpine = Alpine; - -
-
+ +
+
diff --git a/size.json b/size.json index a8b17a9..50f1000 100644 --- a/size.json +++ b/size.json @@ -1,12 +1,12 @@ { "params": { "minified": { - "pretty": "1.88 kB", - "raw": 1877 + "pretty": "1.91 kB", + "raw": 1914 }, "brotli": { - "pretty": "897 B", - "raw": 897 + "pretty": "910 B", + "raw": 910 } }, "xajax": { From 87f3be35277a09c8edf7bcc83e58ff9995bf22dc Mon Sep 17 00:00:00 2001 From: Eric Kwoka <43540491+ekwoka@users.noreply.github.com> Date: Sun, 26 Nov 2023 21:08:56 +0400 Subject: [PATCH 2/4] :memo: Removes non-alpine usage --- packages/params/README.md | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/packages/params/README.md b/packages/params/README.md index d862968..67cf756 100644 --- a/packages/params/README.md +++ b/packages/params/README.md @@ -183,36 +183,6 @@ This plugin does not do anything to manage params not associated with an `$query 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 - -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! - -```ts -import { QueryInterceptor } from '@ekwoka/alpine-history'; - -const params: Record = {}; // internal object structure to store the params - -const myData = { - search: '', -}; - -new QueryInterceptor( - '', - { - raw: (v: T): T => { - v; - }, - }, - params, -) - .as('q') - .initialize(myData, 'search'); -``` - -Not example pretty, but it works! - -This can allow you to use other reactive objects, like Solid Stores. But mostly, this is a hack, but fun! - ## Author 👤 **Eric Kwoka** From 5285bce68e4e570395f94bdd40e384ea68bd6336 Mon Sep 17 00:00:00 2001 From: Eric Kwoka <43540491+ekwoka@users.noreply.github.com> Date: Wed, 29 Nov 2023 21:50:56 +0400 Subject: [PATCH 3/4] :bug: Handles empty query strings and competition --- packages/params/README.md | 21 +++++++++++++++++ packages/params/src/history.ts | 35 +++++++++++++++++++++++++++++ packages/params/src/index.ts | 34 +++++++++++++++++++--------- packages/params/testSite/index.html | 13 +++++------ size.json | 8 +++---- 5 files changed, 89 insertions(+), 22 deletions(-) create mode 100644 packages/params/src/history.ts diff --git a/packages/params/README.md b/packages/params/README.md index 67cf756..bfdc876 100644 --- a/packages/params/README.md +++ b/packages/params/README.md @@ -173,6 +173,27 @@ 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. +## `observeHistory` + +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. + +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. + +```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(); + +history.pushState({}, '', '?count=123'); +``` + +This is not needed to handle `popState` events which are already handled by the plugin. + ## Reactivity All normal reactive behaviors apply to the `$query` interceptor. You can hook up effects to them, and just have a grand old time. 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 f7d39fe..2d5a10d 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 @@ -115,11 +116,21 @@ 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) => { + console.log('url changed', url.search); + const query = fromQueryString(url.search); + updateParams(query); }); const bindQuery = (initial: T) => @@ -165,7 +176,8 @@ const paramEffect = ( return () => { const current = JSON.stringify(params[key]); if (current === previous) return; - setParams(params, method); + console.log('param changed', current); + untrack(() => setParams(params, method)); previous = current; }; }; @@ -174,14 +186,14 @@ const paramEffect = ( * Sets the query string params to the current reactive params */ const setParams = (params: Record, method: UpdateMethod) => { - history[method](intoState(params), '', `?${toQueryString(params)}`); + const queryString = toQueryString(params); + history[method]( + intoState(params), + '', + queryString ? `?${queryString}` : location.pathname, + ); }; -enum UpdateMethod { - replace = 'replaceState', - push = 'pushState', -} - if (import.meta.vitest) { describe('QueryInterceptor', async () => { const Alpine = await import('alpinejs').then((m) => m.default); @@ -353,3 +365,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 59ef49e..22f9e5b 100644 --- a/packages/params/testSite/index.html +++ b/packages/params/testSite/index.html @@ -6,20 +6,17 @@ X RIAS TEST SITE - -
-
+ +
diff --git a/size.json b/size.json index 50f1000..a431b13 100644 --- a/size.json +++ b/size.json @@ -1,12 +1,12 @@ { "params": { "minified": { - "pretty": "1.91 kB", - "raw": 1914 + "pretty": "2.25 kB", + "raw": 2245 }, "brotli": { - "pretty": "910 B", - "raw": 910 + "pretty": "1.05 kB", + "raw": 1052 } }, "xajax": { From 2bf8b972a4f4ecd4e8ee8e5d4572540593352d1c Mon Sep 17 00:00:00 2001 From: Eric Kwoka <43540491+ekwoka@users.noreply.github.com> Date: Wed, 29 Nov 2023 21:58:12 +0400 Subject: [PATCH 4/4] :white_check_mark: Passes test --- packages/params/src/index.ts | 4 +--- size.json | 8 ++++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/params/src/index.ts b/packages/params/src/index.ts index 2d5a10d..d4186d8 100644 --- a/packages/params/src/index.ts +++ b/packages/params/src/index.ts @@ -128,7 +128,6 @@ export const query: PluginCallback = (Alpine) => { }); onURLChange((url) => { - console.log('url changed', url.search); const query = fromQueryString(url.search); updateParams(query); }); @@ -176,7 +175,6 @@ const paramEffect = ( return () => { const current = JSON.stringify(params[key]); if (current === previous) return; - console.log('param changed', current); untrack(() => setParams(params, method)); previous = current; }; @@ -306,7 +304,7 @@ if (import.meta.vitest) { 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', async () => { vi.spyOn(history, UpdateMethod.replace); diff --git a/size.json b/size.json index a431b13..983d322 100644 --- a/size.json +++ b/size.json @@ -1,12 +1,12 @@ { "params": { "minified": { - "pretty": "2.25 kB", - "raw": 2245 + "pretty": "2.18 kB", + "raw": 2178 }, "brotli": { - "pretty": "1.05 kB", - "raw": 1052 + "pretty": "1.03 kB", + "raw": 1029 } }, "xajax": {