Skip to content

Commit 9992398

Browse files
authored
[@xstate/store-angular @xstate/store-svelte @xstate/store-vue] Compare option consistency (#5452)
* Replace 'options' with 'compare' function + changeset * Fix import
1 parent 7545e49 commit 9992398

File tree

10 files changed

+142
-41
lines changed

10 files changed

+142
-41
lines changed

.changeset/stale-buses-stand.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@xstate/store-angular': minor
3+
'@xstate/store-svelte': minor
4+
'@xstate/store-vue': minor
5+
---
6+
7+
Ensured that `compare` argument is a direct comparison function.

packages/xstate-store-angular/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export class CounterComponent {
3636

3737
## API
3838

39-
### `injectStore(store, selector?, options?)`
39+
### `injectStore(store, selector?, compare?)`
4040

4141
An Angular function that creates a signal subscribed to a store, selecting a value via an optional selector function.
4242

@@ -67,7 +67,7 @@ export class CounterComponent {
6767

6868
- `store` - Store created with `createStore()`
6969
- `selector?` - Function to select a value from snapshot
70-
- `options?` - Signal creation options with optional `equal` function and `injector`
70+
- `compare?` - Equality function (default: `===`)
7171

7272
**Returns:** Readonly Angular signal of the selected value
7373

packages/xstate-store-angular/src/index.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,66 @@ describe('@xstate/store-angular', () => {
134134
expect(fixture.nativeElement.textContent).toContain('10');
135135
expect(effectCount).toBe(2); // No re-render for ignored field
136136
});
137+
138+
it('should use custom comparison function', () => {
139+
const store = createStore({
140+
context: { items: [1, 2] },
141+
on: {
142+
same: (ctx) => ({ ...ctx, items: [1, 2] }),
143+
different: (ctx) => ({ ...ctx, items: [3, 4] })
144+
}
145+
});
146+
147+
let effectCount = 0;
148+
149+
@Component({
150+
template: `
151+
<p id="items">{{ items() }}</p>
152+
<button id="same" (click)="sendSame()">Same</button>
153+
<button id="different" (click)="sendDifferent()">Different</button>
154+
`,
155+
standalone: true
156+
})
157+
class TestComponent {
158+
items = injectStore(
159+
store,
160+
(state) => state.context.items,
161+
(a, b) => JSON.stringify(a) === JSON.stringify(b)
162+
);
163+
164+
constructor() {
165+
effect(() => {
166+
console.log(this.items());
167+
effectCount++;
168+
});
169+
}
170+
171+
sendSame() {
172+
store.send({ type: 'same' });
173+
}
174+
175+
sendDifferent() {
176+
store.send({ type: 'different' });
177+
}
178+
}
179+
180+
const fixture = TestBed.createComponent(TestComponent);
181+
fixture.detectChanges();
182+
183+
const debugElement = fixture.debugElement;
184+
185+
expect(effectCount).toBe(1); // Initial
186+
187+
debugElement.query(By.css('#same')).triggerEventHandler('click', null);
188+
fixture.detectChanges();
189+
expect(effectCount).toBe(1); // Same content, should not trigger
190+
191+
debugElement
192+
.query(By.css('#different'))
193+
.triggerEventHandler('click', null);
194+
fixture.detectChanges();
195+
expect(effectCount).toBe(2); // Different content
196+
});
137197
});
138198

139199
describe('re-exports', () => {

packages/xstate-store-angular/src/index.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ import {
88
linkedSignal,
99
runInInjectionContext
1010
} from '@angular/core';
11-
import type { CreateSignalOptions, Signal } from '@angular/core';
12-
import { shallowEqual, type Readable } from '@xstate/store';
11+
import type { Signal } from '@angular/core';
12+
import { type Readable } from '@xstate/store';
13+
14+
function defaultCompare<T>(a: T, b: T) {
15+
return a === b;
16+
}
1317

1418
/**
1519
* An Angular function that creates a signal subscribed to a store, selecting a
@@ -34,32 +38,28 @@ import { shallowEqual, type Readable } from '@xstate/store';
3438
* @param store The store, created from `createStore(…)`
3539
* @param selector A function which takes in the snapshot and returns a selected
3640
* value
37-
* @param options Optional signal creation options with compare function and
38-
* injector
41+
* @param compare An optional function which compares the selected value to the
42+
* previous value
3943
* @returns A readonly Signal of the selected value
4044
*/
4145
export function injectStore<TStore extends Readable<any>, TSelected>(
4246
store: TStore,
4347
selector?: (state: TStore extends Readable<infer T> ? T : never) => TSelected,
44-
options?: CreateSignalOptions<TSelected> & { injector?: Injector }
48+
compare?: (a: TSelected, b: TSelected) => boolean
4549
): Signal<TSelected>;
4650
export function injectStore<TStore extends Readable<any>, TSelected>(
4751
store: TStore,
4852
selector: (
4953
state: TStore extends Readable<infer T> ? T : never
5054
) => TSelected = (d) => d as TSelected,
51-
options: CreateSignalOptions<TSelected> & { injector?: Injector } = {
52-
equal: shallowEqual
53-
}
55+
compare: (a: TSelected, b: TSelected) => boolean = defaultCompare
5456
): Signal<TSelected> {
55-
if (!options.injector) {
56-
assertInInjectionContext(injectStore);
57-
options.injector = inject(Injector);
58-
}
57+
assertInInjectionContext(injectStore);
58+
const injector = inject(Injector);
5959

60-
return runInInjectionContext(options.injector, () => {
60+
return runInInjectionContext(injector, () => {
6161
const destroyRef = inject(DestroyRef);
62-
const slice = linkedSignal(() => selector(store.get()), options);
62+
const slice = linkedSignal(() => selector(store.get()), { equal: compare });
6363

6464
const { unsubscribe } = store.subscribe((s) => {
6565
slice.set(selector(s));

packages/xstate-store-svelte/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const count = useSelector(store, (s) => s.context.count);
3232

3333
## API
3434

35-
### `useSelector(store, selector?, options?)`
35+
### `useSelector(store, selector?, compare?)`
3636

3737
Creates a Svelte readable store that subscribes to an XState store and returns a selected value.
3838

@@ -60,7 +60,7 @@ const snapshot = useSelector(store);
6060

6161
- `store` - Store created with `createStore()`
6262
- `selector?` - Function to select a value from snapshot
63-
- `options?` - Object with optional `compare` equality function
63+
- `compare?` - Equality function (default: `===`)
6464

6565
**Returns:** Svelte readable store
6666

packages/xstate-store-svelte/src/index.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,11 @@ describe('@xstate/store-svelte', () => {
5555
});
5656

5757
let updateCount = 0;
58-
const items$ = useSelector(store, (s) => s.context.items, {
59-
compare: (a, b) => JSON.stringify(a) === JSON.stringify(b)
60-
});
58+
const items$ = useSelector(
59+
store,
60+
(s) => s.context.items,
61+
(a, b) => JSON.stringify(a) === JSON.stringify(b)
62+
);
6163

6264
const unsubscribe = items$.subscribe(() => {
6365
updateCount++;

packages/xstate-store-svelte/src/index.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
export * from '@xstate/store';
22

3-
import { shallowEqual, type Readable as XStateReadable } from '@xstate/store';
3+
import { type Readable as XStateReadable } from '@xstate/store';
44
import type { Readable } from 'svelte/store';
55

6-
type EqualityFn<T> = (objA: T, objB: T) => boolean;
7-
8-
interface UseSelectorOptions<T> {
9-
compare?: EqualityFn<T>;
6+
function defaultCompare<T>(a: T, b: T) {
7+
return a === b;
108
}
119

1210
/**
@@ -29,18 +27,17 @@ interface UseSelectorOptions<T> {
2927
* @param store The store, created from `createStore(…)`
3028
* @param selector A function which takes in the snapshot and returns a selected
3129
* value
32-
* @param options Optional configuration with compare function
30+
* @param compare An optional function which compares the selected value to the
31+
* previous value
3332
* @returns A Svelte readable store
3433
*/
3534
export function useSelector<TStore extends XStateReadable<any>, TSelected>(
3635
store: TStore,
3736
selector: (
3837
state: TStore extends XStateReadable<infer T> ? T : never
3938
) => TSelected = (d) => d as any,
40-
options: UseSelectorOptions<TSelected> = {}
39+
compare: (a: TSelected, b: TSelected) => boolean = defaultCompare
4140
): Readable<TSelected> {
42-
const compare = options.compare ?? shallowEqual;
43-
4441
return {
4542
subscribe(run) {
4643
let currentValue = selector(store.get());

packages/xstate-store-vue/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const count = useSelector(store, (s) => s.context.count);
3232

3333
## API
3434

35-
### `useSelector(store, selector?, options?)`
35+
### `useSelector(store, selector?, compare?)`
3636

3737
A composable that subscribes to a store and returns a selected value as a readonly ref.
3838

@@ -62,7 +62,7 @@ const snapshot = useSelector(store);
6262

6363
- `store` - Store created with `createStore()`
6464
- `selector?` - Function to select a value from snapshot
65-
- `options?` - Object with optional `compare` equality function
65+
- `compare?` - Equality function (default: `===`)
6666

6767
**Returns:** Readonly ref of the selected value
6868

packages/xstate-store-vue/src/index.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { fireEvent, render } from '@testing-library/vue';
22
import TestCounter from './TestCounter.vue';
3-
import { createStore, createAtom } from './index';
3+
import { createStore, createAtom, useSelector } from './index';
44

55
describe('@xstate/store-vue', () => {
66
describe('useSelector', () => {
@@ -15,6 +15,43 @@ describe('@xstate/store-vue', () => {
1515
await fireEvent.click(incrementEl);
1616
expect(countEl.textContent).toBe('1');
1717
});
18+
19+
it('should use custom comparison function', async () => {
20+
const store = createStore({
21+
context: { items: [1, 2] },
22+
on: {
23+
same: (ctx) => ({ ...ctx, items: [1, 2] }),
24+
different: (ctx) => ({ ...ctx, items: [3, 4] })
25+
}
26+
});
27+
28+
let updateCount = 0;
29+
const items = useSelector(
30+
store,
31+
(s) => s.context.items,
32+
(a, b) => JSON.stringify(a) === JSON.stringify(b)
33+
);
34+
35+
// Vue refs need to be watched to track updates
36+
const { watch } = await import('vue');
37+
watch(
38+
items,
39+
() => {
40+
updateCount++;
41+
},
42+
{ immediate: true }
43+
);
44+
45+
expect(updateCount).toBe(1); // Initial
46+
47+
store.send({ type: 'same' }); // Same content, should not trigger
48+
await new Promise((r) => setTimeout(r, 0));
49+
expect(updateCount).toBe(1);
50+
51+
store.send({ type: 'different' }); // Different content
52+
await new Promise((r) => setTimeout(r, 0));
53+
expect(updateCount).toBe(2);
54+
});
1855
});
1956

2057
describe('re-exports', () => {

packages/xstate-store-vue/src/index.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@ export * from '@xstate/store';
22

33
import { readonly, ref, toRaw, watch } from 'vue-demi';
44
import type { Ref } from 'vue-demi';
5-
import { shallowEqual, type Readable } from '@xstate/store';
5+
import { type Readable } from '@xstate/store';
66

7-
type EqualityFn<T> = (objA: T, objB: T) => boolean;
8-
9-
interface UseSelectorOptions<T> {
10-
compare?: EqualityFn<T>;
7+
function defaultCompare<T>(a: T, b: T) {
8+
return a === b;
119
}
1210

1311
/**
@@ -32,18 +30,18 @@ interface UseSelectorOptions<T> {
3230
* @param store The store, created from `createStore(…)`
3331
* @param selector A function which takes in the snapshot and returns a selected
3432
* value
35-
* @param options Optional configuration with compare function
33+
* @param compare An optional function which compares the selected value to the
34+
* previous value
3635
* @returns A readonly ref of the selected value
3736
*/
3837
export function useSelector<TStore extends Readable<any>, TSelected>(
3938
store: TStore,
4039
selector: (
4140
state: TStore extends Readable<infer T> ? T : never
4241
) => TSelected = (d) => d as any,
43-
options: UseSelectorOptions<TSelected> = {}
42+
compare: (a: TSelected, b: TSelected) => boolean = defaultCompare
4443
): Readonly<Ref<TSelected>> {
4544
const slice = ref(selector(store.get())) as Ref<TSelected>;
46-
const compare = options.compare ?? shallowEqual;
4745

4846
watch(
4947
() => store,

0 commit comments

Comments
 (0)