@matheuspuel/state-machine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.js +34 -0
- package/.eslintrc.production.js +11 -0
- package/.prettierrc +9 -0
- package/package.json +42 -0
- package/pnpm-workspace.yaml +2 -0
- package/src/index.ts +54 -0
- package/src/machines/index.ts +1 -0
- package/src/machines/simple.ts +7 -0
- package/src/react/context.tsx +25 -0
- package/src/react/index.ts +3 -0
- package/src/react/useSelector.ts +25 -0
- package/src/react/useStateMachine.ts +42 -0
- package/src/runtime/index.ts +56 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +5 -0
package/.eslintrc.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
extends: [
|
|
3
|
+
'eslint:recommended',
|
|
4
|
+
'plugin:@typescript-eslint/recommended',
|
|
5
|
+
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
|
6
|
+
'plugin:@typescript-eslint/strict',
|
|
7
|
+
],
|
|
8
|
+
parserOptions: {
|
|
9
|
+
tsconfigRootDir: __dirname,
|
|
10
|
+
project: ['./tsconfig.json'],
|
|
11
|
+
ecmaVersion: 'latest',
|
|
12
|
+
sourceType: 'module',
|
|
13
|
+
},
|
|
14
|
+
ignorePatterns: [
|
|
15
|
+
'/.vscode/*',
|
|
16
|
+
'/coverage/*',
|
|
17
|
+
'/dist/*',
|
|
18
|
+
'/experiments/*',
|
|
19
|
+
'/node_modules/*',
|
|
20
|
+
'.eslintrc.js',
|
|
21
|
+
'.eslintrc.production.js',
|
|
22
|
+
],
|
|
23
|
+
rules: {
|
|
24
|
+
// already checked by typescript
|
|
25
|
+
'@typescript-eslint/no-redeclare': 'off',
|
|
26
|
+
// warn instead of error
|
|
27
|
+
'@typescript-eslint/no-unused-vars': 'warn',
|
|
28
|
+
// allow namespace declaration for types
|
|
29
|
+
'@typescript-eslint/no-namespace': ['error', { allowDeclarations: true }],
|
|
30
|
+
// warn instead of error to prevent covering more specific errors
|
|
31
|
+
'@typescript-eslint/no-unsafe-return': 'warn',
|
|
32
|
+
'@typescript-eslint/no-unsafe-call': 'warn',
|
|
33
|
+
},
|
|
34
|
+
}
|
package/.prettierrc
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@matheuspuel/state-machine",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"module": "index.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./package.json": "./package.json",
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./mahcines": "./src/machines/index.ts",
|
|
10
|
+
"./react": "./src/react/index.ts",
|
|
11
|
+
"./runtime": "./src/runtime/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"lint": "eslint . --max-warnings 0 --config .eslintrc.production.js",
|
|
15
|
+
"lint:fix": "eslint . --fix --config .eslintrc.production.js",
|
|
16
|
+
"typecheck": "tsc",
|
|
17
|
+
"test": "vitest"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@matheuspuel/optic": "^0.1.0",
|
|
21
|
+
"effect": "^3.18.1",
|
|
22
|
+
"react": "19.1.0",
|
|
23
|
+
"use-sync-external-store": "^1.5.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@effect/language-service": "^0.41.1",
|
|
27
|
+
"@effect/vitest": "^0.26.0",
|
|
28
|
+
"@types/react": "~19.1.17",
|
|
29
|
+
"@types/use-sync-external-store": "^1.5.0",
|
|
30
|
+
"@typescript-eslint/parser": "^8.29.1",
|
|
31
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
32
|
+
"cross-env": "^7.0.3",
|
|
33
|
+
"eslint": "^8.57.1",
|
|
34
|
+
"eslint-config-prettier": "^10.1.1",
|
|
35
|
+
"eslint-plugin-prettier": "^5.2.6",
|
|
36
|
+
"prettier": "^3.6.2",
|
|
37
|
+
"tsx": "^4.20.6",
|
|
38
|
+
"typescript": "^5.9.3",
|
|
39
|
+
"vite": "^6.3.5",
|
|
40
|
+
"vitest": "^3.2.4"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Optic } from '@matheuspuel/optic'
|
|
2
|
+
|
|
3
|
+
export type MachineStoreBase<State> = {
|
|
4
|
+
get: () => State
|
|
5
|
+
update: (stateUpdate: (_: State) => State) => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type MachineStore<State> = MachineStoreBase<State> & {
|
|
9
|
+
zoom: <A, Optional extends boolean>(
|
|
10
|
+
f: (optic: Optic<State, State>) => Optic<A, State, Optional>,
|
|
11
|
+
) => MachineStoreBase<A>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const makeMachineStore = <State>(
|
|
15
|
+
base: MachineStoreBase<State>,
|
|
16
|
+
): MachineStore<State> => ({
|
|
17
|
+
...base,
|
|
18
|
+
zoom: zoomF => ({
|
|
19
|
+
get: () => (zoomF(Optic.id<State>()) as any).get(base.get()),
|
|
20
|
+
getOption: () => zoomF(Optic.id<State>()).getOption(base.get()),
|
|
21
|
+
update: f => base.update(zoomF(Optic.id<State>()).update(f)),
|
|
22
|
+
}),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export type StateAction<A extends unknown[], B> = (...args: A) => B
|
|
26
|
+
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
28
|
+
export type AnyStateAction = StateAction<any[], unknown>
|
|
29
|
+
|
|
30
|
+
export type AnyStateActions = Record<string, AnyStateAction>
|
|
31
|
+
|
|
32
|
+
export type StateMachine<State, Actions extends AnyStateActions> = {
|
|
33
|
+
initialState: State
|
|
34
|
+
actions: (machine: { Store: MachineStore<State> }) => Actions
|
|
35
|
+
start?: (machine: {
|
|
36
|
+
Store: MachineStore<State>
|
|
37
|
+
}) => undefined | Promise<unknown>
|
|
38
|
+
onUpdate?: (state: State) => void | Promise<void>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const makeStateMachine =
|
|
42
|
+
<State>() =>
|
|
43
|
+
<Actions extends AnyStateActions>(args: StateMachine<State, Actions>) =>
|
|
44
|
+
args
|
|
45
|
+
|
|
46
|
+
export type PreparedStateActions<Actions extends AnyStateActions> = Actions
|
|
47
|
+
|
|
48
|
+
export const prepareStateMachineActions = <
|
|
49
|
+
State,
|
|
50
|
+
Actions extends AnyStateActions,
|
|
51
|
+
>(
|
|
52
|
+
machine: StateMachine<State, Actions>,
|
|
53
|
+
store: MachineStore<State>,
|
|
54
|
+
): PreparedStateActions<Actions> => machine.actions({ Store: store })
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './simple'
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { AnyStateActions, PreparedStateActions, StateMachine } from '..'
|
|
3
|
+
import { useStateMachine } from './useStateMachine'
|
|
4
|
+
|
|
5
|
+
export const makeStateMachineContext = <State, Actions extends AnyStateActions>(
|
|
6
|
+
machine: StateMachine<State, Actions>,
|
|
7
|
+
) => {
|
|
8
|
+
const Context = React.createContext<{
|
|
9
|
+
state: State
|
|
10
|
+
actions: PreparedStateActions<Actions>
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
|
|
12
|
+
}>(undefined as any)
|
|
13
|
+
return {
|
|
14
|
+
Provider: (props: { children: React.ReactNode }) => {
|
|
15
|
+
const value = useStateMachine(machine)
|
|
16
|
+
return <Context.Provider value={value}>{props.children}</Context.Provider>
|
|
17
|
+
},
|
|
18
|
+
useActions: () => React.useContext(Context).actions,
|
|
19
|
+
useSelector: <A,>(fn: (state: State) => A) => {
|
|
20
|
+
const state = React.useContext(Context).state
|
|
21
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
22
|
+
return React.useMemo(() => fn(state), [state])
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Effect, Equal, Equivalence } from 'effect'
|
|
2
|
+
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'
|
|
3
|
+
import { AnyStateActions } from '../index.js'
|
|
4
|
+
import { RunningStateMachine } from '../runtime'
|
|
5
|
+
|
|
6
|
+
export const makeUseSelector =
|
|
7
|
+
<State, Actions extends AnyStateActions>(
|
|
8
|
+
stateMachine: RunningStateMachine<State, Actions>,
|
|
9
|
+
) =>
|
|
10
|
+
<A>(
|
|
11
|
+
selector: (state: State) => A,
|
|
12
|
+
equivalence?: Equivalence.Equivalence<A>,
|
|
13
|
+
): A =>
|
|
14
|
+
useSyncExternalStoreWithSelector<State, A>(
|
|
15
|
+
onChange => {
|
|
16
|
+
const subscription = stateMachine
|
|
17
|
+
.subscribe(() => Effect.sync(onChange))
|
|
18
|
+
.pipe(Effect.runSync)
|
|
19
|
+
return () => subscription.unsubscribe.pipe(Effect.runSync)
|
|
20
|
+
},
|
|
21
|
+
() => stateMachine.ref.get.pipe(Effect.runSync),
|
|
22
|
+
undefined,
|
|
23
|
+
selector,
|
|
24
|
+
equivalence ?? Equal.equivalence<A>(),
|
|
25
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import {
|
|
3
|
+
AnyStateActions,
|
|
4
|
+
makeMachineStore,
|
|
5
|
+
prepareStateMachineActions,
|
|
6
|
+
StateMachine,
|
|
7
|
+
} from '..'
|
|
8
|
+
|
|
9
|
+
export const useStateMachine = <State, Actions extends AnyStateActions>(
|
|
10
|
+
stateMachine:
|
|
11
|
+
| StateMachine<State, Actions>
|
|
12
|
+
| (() => StateMachine<State, Actions>),
|
|
13
|
+
) => {
|
|
14
|
+
const machine =
|
|
15
|
+
typeof stateMachine === 'function' ? stateMachine() : stateMachine
|
|
16
|
+
const [state, setState] = React.useState(() => machine.initialState)
|
|
17
|
+
const stateRef = React.useRef(machine.initialState)
|
|
18
|
+
const getState = React.useMemo(() => () => stateRef.current, [])
|
|
19
|
+
const store = React.useMemo(
|
|
20
|
+
() =>
|
|
21
|
+
makeMachineStore<State>({
|
|
22
|
+
get: getState,
|
|
23
|
+
update: fn => {
|
|
24
|
+
const previousState = getState()
|
|
25
|
+
const state = fn(previousState)
|
|
26
|
+
stateRef.current = state
|
|
27
|
+
void machine.onUpdate?.(state)
|
|
28
|
+
setState(() => state)
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
[machine, getState],
|
|
32
|
+
)
|
|
33
|
+
const actions = React.useMemo(
|
|
34
|
+
() => prepareStateMachineActions(machine, store),
|
|
35
|
+
[machine, store],
|
|
36
|
+
)
|
|
37
|
+
React.useEffect(() => {
|
|
38
|
+
void machine.start?.({ Store: store })
|
|
39
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
40
|
+
}, [])
|
|
41
|
+
return { state, actions }
|
|
42
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Array, Effect, Ref } from 'effect'
|
|
2
|
+
import {
|
|
3
|
+
AnyStateActions,
|
|
4
|
+
makeMachineStore,
|
|
5
|
+
PreparedStateActions,
|
|
6
|
+
prepareStateMachineActions,
|
|
7
|
+
StateMachine,
|
|
8
|
+
} from '..'
|
|
9
|
+
|
|
10
|
+
type SubscriptionTask<State> = (state: State) => Effect.Effect<void>
|
|
11
|
+
|
|
12
|
+
export type RunningStateMachine<State, Actions extends AnyStateActions> = {
|
|
13
|
+
ref: Ref.Ref<State>
|
|
14
|
+
actions: PreparedStateActions<Actions>
|
|
15
|
+
startPromise: Promise<unknown> | undefined
|
|
16
|
+
subscribe: (
|
|
17
|
+
task: SubscriptionTask<State>,
|
|
18
|
+
) => Effect.Effect<
|
|
19
|
+
{ unsubscribe: Effect.Effect<void, never, never> },
|
|
20
|
+
never,
|
|
21
|
+
never
|
|
22
|
+
>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const runStateMachine = <State, Actions extends AnyStateActions>(
|
|
26
|
+
machine: StateMachine<State, Actions>,
|
|
27
|
+
): RunningStateMachine<State, Actions> => {
|
|
28
|
+
const subscriptionsRef = Ref.unsafeMake<SubscriptionTask<State>[]>([])
|
|
29
|
+
const ref = Ref.unsafeMake(machine.initialState)
|
|
30
|
+
const store = makeMachineStore<State>({
|
|
31
|
+
get: () => ref.get.pipe(Effect.runSync),
|
|
32
|
+
update: fn =>
|
|
33
|
+
Effect.gen(function* () {
|
|
34
|
+
const previousState = yield* ref.get
|
|
35
|
+
const state = fn(previousState)
|
|
36
|
+
yield* Ref.set(ref, state)
|
|
37
|
+
yield* Effect.forEach(yield* subscriptionsRef.get, _ => _(state)).pipe(
|
|
38
|
+
Effect.exit,
|
|
39
|
+
)
|
|
40
|
+
void machine.onUpdate?.(state)
|
|
41
|
+
}).pipe(Effect.runSync),
|
|
42
|
+
})
|
|
43
|
+
const actions = prepareStateMachineActions(machine, store)
|
|
44
|
+
const subscribe = (task: SubscriptionTask<State>) =>
|
|
45
|
+
Effect.gen(function* () {
|
|
46
|
+
yield* Ref.update(subscriptionsRef, Array.append(task))
|
|
47
|
+
return {
|
|
48
|
+
unsubscribe: Ref.update(
|
|
49
|
+
subscriptionsRef,
|
|
50
|
+
Array.filter(_ => _ !== task),
|
|
51
|
+
),
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
const startPromise = machine.start?.({ Store: store })
|
|
55
|
+
return { ref, actions, subscribe, startPromise }
|
|
56
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"noEmit": true,
|
|
4
|
+
"skipLibCheck": true,
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"target": "ESNext",
|
|
7
|
+
"jsx": "preserve",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"noFallthroughCasesInSwitch": true,
|
|
11
|
+
"plugins": [{ "name": "@effect/language-service" }]
|
|
12
|
+
},
|
|
13
|
+
"include": ["**/*.ts", "**/*.tsx"]
|
|
14
|
+
}
|