Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/quick-waves-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@xstate/store-angular': major
'@xstate/store-preact': major
'@xstate/store-svelte': major
'@xstate/store-react': major
'@xstate/store-solid': major
'@xstate/store-vue': major
---

Added new framework adapter packages for `@xstate/store`:

- `@xstate/store-react` - React hook (`useSelector`, `useStore`, `useAtom`, `createStoreHook`)
- `@xstate/store-solid` - Solid.js hook (`useSelector`)
- `@xstate/store-vue` - Vue composable (`useSelector`)
- `@xstate/store-svelte` - Svelte store (`useSelector`)
- `@xstate/store-preact` - Preact hook (`useSelector`)
- `@xstate/store-angular` - Angular signal (`injectStore`)

All packages re-export `@xstate/store` for convenience.

Also deprecated:

- `@xstate/store/react` (use `@xstate/store-react` instead)
- `@xstate/store/solid` (use `@xstate/store-solid` instead)
6 changes: 5 additions & 1 deletion .knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@
"solid-js",
"xstate",
"@types/ws",
"vitest"
"vitest",
// peer dependencies that may be needed but not directly imported
"@angular/compiler-cli",
"@sveltejs/vite-plugin-svelte",
"@testing-library/svelte"
],
"vitest": {
"config": ["vitest.config.mts", "vitest.config.**.mts"],
Expand Down
62 changes: 62 additions & 0 deletions packages/xstate-store-angular/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"name": "@xstate/store-angular",
"version": "0.0.1",
"description": "XState Store for Angular",
"keywords": [
"store",
"state",
"angular"
],
"author": "David Khourshid <[email protected]>",
"homepage": "https://github.com/statelyai/xstate/tree/main/packages/xstate-store-angular#readme",
"license": "MIT",
"main": "dist/xstate-store-angular.cjs.js",
"module": "dist/xstate-store-angular.esm.js",
"exports": {
".": {
"types": {
"import": "./dist/xstate-store-angular.cjs.mjs",
"default": "./dist/xstate-store-angular.cjs.js"
},
"module": "./dist/xstate-store-angular.esm.js",
"import": "./dist/xstate-store-angular.cjs.mjs",
"default": "./dist/xstate-store-angular.cjs.js"
},
"./package.json": "./package.json"
},
"types": "dist/xstate-store-angular.cjs.d.ts",
"sideEffects": false,
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "git+ssh://[email protected]/statelyai/xstate.git"
},
"bugs": {
"url": "https://github.com/statelyai/xstate/issues"
},
"dependencies": {
"@xstate/store": "workspace:^"
},
"peerDependencies": {
"@angular/core": "^19.0.0"
},
"devDependencies": {
"@analogjs/vite-plugin-angular": "^1.21.2",
"@angular/build": "^19.0.0",
"@angular/common": "^19.0.0",
"@angular/compiler": "^19.0.0",
"@angular/compiler-cli": "^19.0.0",
"@angular/core": "^19.0.0",
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"jsdom": "^26.0.0",
"zone.js": "^0.15.0"
},
"preconstruct": {
"entrypoints": [
"./index.ts"
]
}
}
155 changes: 155 additions & 0 deletions packages/xstate-store-angular/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { describe, expect, it } from 'vitest';
import { Component, effect } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { createStore, createAtom, injectStore } from './index';

describe('@xstate/store-angular', () => {
describe('injectStore', () => {
it('should select state using a selector', () => {
const store = createStore({
context: { count: 0, ignored: 1 },
on: {}
});

@Component({
template: `<p>Count: {{ count() }}</p>`,
standalone: true
})
class TestComponent {
count = injectStore(store, (state) => state.context.count);
}

const fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();

expect(fixture.nativeElement.textContent).toContain('Count: 0');
});

it('should update when store changes', () => {
const store = createStore({
context: { count: 0 },
on: {
inc: (ctx) => ({ ...ctx, count: ctx.count + 1 })
}
});

@Component({
template: `
<p id="count">{{ count() }}</p>
<button id="increment" (click)="increment()">+</button>
`,
standalone: true
})
class TestComponent {
count = injectStore(store, (state) => state.context.count);

increment() {
store.send({ type: 'inc' });
}
}

const fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();

const debugElement = fixture.debugElement;

expect(
debugElement.query(By.css('#count')).nativeElement.textContent
).toBe('0');

debugElement
.query(By.css('#increment'))
.triggerEventHandler('click', null);

fixture.detectChanges();
expect(
debugElement.query(By.css('#count')).nativeElement.textContent
).toBe('1');
});

it('should only re-render when selected state changes', () => {
const store = createStore({
context: { selected: 0, ignored: 1 },
on: {
updateSelected: (ctx) => ({ ...ctx, selected: 10 }),
updateIgnored: (ctx) => ({ ...ctx, ignored: 10 })
}
});

let effectCount = 0;

@Component({
template: `
<p id="value">{{ value() }}</p>
<button id="updateSelected" (click)="updateSelected()">
Update selected
</button>
<button id="updateIgnored" (click)="updateIgnored()">
Update ignored
</button>
`,
standalone: true
})
class TestComponent {
value = injectStore(store, (state) => state.context.selected);

constructor() {
effect(() => {
console.log(this.value());
effectCount++;
});
}

updateSelected() {
store.send({ type: 'updateSelected' });
}

updateIgnored() {
store.send({ type: 'updateIgnored' });
}
}

const fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();

const debugElement = fixture.debugElement;

expect(fixture.nativeElement.textContent).toContain('0');
expect(effectCount).toBe(1);

debugElement
.query(By.css('#updateSelected'))
.triggerEventHandler('click', null);

fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('10');
expect(effectCount).toBe(2);

debugElement
.query(By.css('#updateIgnored'))
.triggerEventHandler('click', null);

fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('10');
expect(effectCount).toBe(2); // No re-render for ignored field
});
});

describe('re-exports', () => {
it('should re-export createStore from @xstate/store', () => {
expect(createStore).toBeDefined();
const store = createStore({
context: { value: 'test' },
on: {}
});
expect(store.get().context.value).toBe('test');
});

it('should re-export createAtom from @xstate/store', () => {
expect(createAtom).toBeDefined();
const atom = createAtom(123);
expect(atom.get()).toBe(123);
});
});
});
74 changes: 74 additions & 0 deletions packages/xstate-store-angular/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
export * from '@xstate/store';

import {
DestroyRef,
Injector,
assertInInjectionContext,
inject,
linkedSignal,
runInInjectionContext
} from '@angular/core';
import type { CreateSignalOptions, Signal } from '@angular/core';
import { shallowEqual, type Readable } from '@xstate/store';

/**
* An Angular function that creates a signal subscribed to a store, selecting a
* value via an optional selector function.
*
* @example
*
* ```ts
* import { Component } from '@angular/core';
* import { store } from './store';
* import { injectStore } from '@xstate/store-angular';
*
* @Component({
* selector: 'app-counter',
* template: `<div>{{ count() }}</div>`
* })
* export class CounterComponent {
* count = injectStore(store, (state) => state.context.count);
* }
* ```
*
* @param store The store, created from `createStore(…)`
* @param selector A function which takes in the snapshot and returns a selected
* value
* @param options Optional signal creation options with compare function and
* injector
* @returns A readonly Signal of the selected value
*/
export function injectStore<TStore extends Readable<any>, TSelected>(
store: TStore,
selector?: (state: TStore extends Readable<infer T> ? T : never) => TSelected,
options?: CreateSignalOptions<TSelected> & { injector?: Injector }
): Signal<TSelected>;
export function injectStore<TStore extends Readable<any>, TSelected>(
store: TStore,
selector: (
state: TStore extends Readable<infer T> ? T : never
) => TSelected = (d) => d as TSelected,
options: CreateSignalOptions<TSelected> & { injector?: Injector } = {
equal: shallowEqual
}
): Signal<TSelected> {
if (!options.injector) {
assertInInjectionContext(injectStore);
options.injector = inject(Injector);
}

return runInInjectionContext(options.injector, () => {
const destroyRef = inject(DestroyRef);
const slice = linkedSignal(() => selector(store.get()), options);

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

destroyRef.onDestroy(() => {
unsubscribe();
});

return slice.asReadonly();
});
}
12 changes: 12 additions & 0 deletions packages/xstate-store-angular/src/test-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import '@analogjs/vite-plugin-angular/setup-vitest';

import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';
import { getTestBed } from '@angular/core/testing';

getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting()
);
8 changes: 8 additions & 0 deletions packages/xstate-store-angular/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"include": ["src"],
"compilerOptions": {
"experimentalDecorators": true,
"noEmit": true
}
}
17 changes: 17 additions & 0 deletions packages/xstate-store-angular/vitest.config.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
import path from 'path';

export default defineConfig({
test: {
name: '@xstate/store-angular',
include: ['src/**/*.test.{ts,tsx}'],
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test-setup.ts']
},
resolve: {
alias: {
'@xstate/store': path.resolve(__dirname, '../xstate-store/src/index.ts')
}
}
});
Loading