Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Derived Stores #40

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const config = {
],
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/method-signature-style': 'error',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-empty-interface': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
Expand Down
2 changes: 1 addition & 1 deletion docs/framework/solid/reference/useStore.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ title: Use Store
id: useStore
---

Please see [/packages/solid-store/src/index.ts](https://github.com/tanstack/store/tree/main/packages/solid-store/src/index.ts)
Please see [/packages/solid-store/src/store.ts](https://github.com/tanstack/store/tree/main/packages/solid-store/src/index.ts)
2 changes: 1 addition & 1 deletion docs/framework/vue/reference/useStore.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ title: Use Store
id: useStore
---

Please see [/packages/vue-store/src/index.ts](https://github.com/tanstack/store/tree/main/packages/vue-store/src/index.ts)
Please see [/packages/vue-store/src/store.ts](https://github.com/tanstack/store/tree/main/packages/vue-store/src/index.ts)
9 changes: 9 additions & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
"packages/angular-store": {
"ignoreDependencies": ["@angular/compiler-cli"]
},
"packages/store": {
"ignore": ["src/tests/derived.bench.ts"],
"ignoreDependencies": [
"@angular/core",
"@preact/signals",
"solid-js",
"vue"
]
},
"packages/vue-store": {
"ignoreDependencies": ["vue2", "vue2.7"]
}
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,13 @@
"react-dom": "^18.2.0",
"rimraf": "^5.0.5",
"sherif": "^0.7.0",
"solid-js": "^1.7.8",
"solid-js": "^1.8.14",
"typescript": "^5.2.2",
"typescript49": "npm:typescript@4.9",
"typescript50": "npm:typescript@5.0",
"typescript51": "npm:typescript@5.1",
"vite": "^5.1.0",
"vitest": "^1.2.2",
"vue": "^3.3.4"
"vue": "^3.4.15"
}
}
2 changes: 1 addition & 1 deletion packages/solid-store/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
"@tanstack/store": "workspace:*"
},
"devDependencies": {
"solid-js": "^1.7.8",
"solid-js": "^1.8.14",
"vite-plugin-solid": "^2.8.0"
}
}
9 changes: 8 additions & 1 deletion packages/store/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"test:types:versions52": "tsc",
"test:types": "pnpm run \"/^test:types:versions.*/\"",
"test:lib": "vitest",
"test:bench": "vitest bench",
"test:lib:dev": "pnpm run test:lib --watch",
"test:build": "publint --strict",
"build": "vite build"
Expand Down Expand Up @@ -51,5 +52,11 @@
"files": [
"dist",
"src"
]
],
"devDependencies": {
"@angular/core": "^17.1.2",
"solid-js": "^1.8.14",
"@preact/signals": "^1.2.2",
"vue": "^3.4.15"
}
}
115 changes: 115 additions & 0 deletions packages/store/src/derived.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Store } from './store'
import type { Listener } from './types'

interface DerivedOptions<TState> {
onSubscribe?: (listener: Listener, derived: Derived<TState>) => () => void
onUpdate?: () => void
}

export type Deps = Array<Derived<any> | Store<any>>

export class Derived<TState> {
_store!: Store<TState>
rootStores = new Set<Store<unknown>>()
deps: Deps

// Functions representing the subscriptions. Call a function to cleanup
_subscriptions: Array<() => void> = []

// What store called the current update, if any
_whatStoreIsCurrentlyInUse: Store<unknown> | null = null

constructor(deps: Deps, fn: () => TState, options?: DerivedOptions<TState>) {
this.deps = deps
this._store = new Store(fn(), {
onSubscribe: options?.onSubscribe?.bind(this) as never,
onUpdate: options?.onUpdate,
})
/**
* This is here to solve the pyramid dependency problem where:
* A
* / \
* B C
* \ /
* D
*
* Where we deeply traverse this tree, how do we avoid D being recomputed twice; once when B is updated, once when C is.
*
* To solve this, we create linkedDeps that allows us to sync avoid writes to the state until all of the deps have been
* resolved.
*
* This is a record of stores, because derived stores are not able to write values to, but stores are
*/
const storeToDerived = new Map<Store<unknown>, Set<Derived<unknown>>>()
const derivedToStore = new Map<Derived<unknown>, Set<Store<unknown>>>()

const updateStoreToDerived = (
store: Store<unknown>,
dep: Derived<unknown>,
) => {
const prevDerivesForStore = storeToDerived.get(store) || new Set()
prevDerivesForStore.add(dep)
storeToDerived.set(store, prevDerivesForStore)
}
for (const dep of deps) {
if (dep instanceof Derived) {
derivedToStore.set(dep, dep.rootStores)
for (const store of dep.rootStores) {
this.rootStores.add(store)
updateStoreToDerived(store, dep)
}
} else if (dep instanceof Store) {
this.rootStores.add(dep)
updateStoreToDerived(dep, this as Derived<unknown>)
}
}

let __depsThatHaveWrittenThisTick: Deps = []

for (const dep of deps) {
const isDepAStore = dep instanceof Store
let relatedLinkedDerivedVals: null | Set<Derived<unknown>> = null

const unsub = dep.subscribe(() => {
const store = isDepAStore ? dep : dep._whatStoreIsCurrentlyInUse
this._whatStoreIsCurrentlyInUse = store
if (store) {
relatedLinkedDerivedVals = storeToDerived.get(store) ?? null
}

__depsThatHaveWrittenThisTick.push(dep)
if (
!relatedLinkedDerivedVals ||
__depsThatHaveWrittenThisTick.length === relatedLinkedDerivedVals.size
) {
// Yay! All deps are resolved - write the value of this derived
this._store.setState(fn)
// Cleanup the deps that have written this tick
__depsThatHaveWrittenThisTick = []
this._whatStoreIsCurrentlyInUse = null
return
}
})

this._subscriptions.push(unsub)
}
}

get state() {
return this._store.state
}

cleanup = () => {
for (const cleanup of this._subscriptions) {
cleanup()
}
};

[Symbol.dispose]() {
this.cleanup()
}

subscribe = (listener: Listener) => {
return this._store.subscribe(listener)
}
}
22 changes: 22 additions & 0 deletions packages/store/src/effect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Derived } from './derived'
import type { Deps } from './derived'

export class Effect {
_derived: Derived<void>

constructor(items: Deps, effectFn: () => void) {
this._derived = new Derived(items, () => {}, {
onUpdate() {
effectFn()
},
})
}

cleanup() {
this._derived.cleanup()
}

[Symbol.dispose]() {
this.cleanup()
}
}
74 changes: 4 additions & 70 deletions packages/store/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,4 @@
export type AnyUpdater = (...args: any[]) => any

export type Listener = () => void

interface StoreOptions<
TState,
TUpdater extends AnyUpdater = (cb: TState) => TState,
> {
updateFn?: (previous: TState) => (updater: TUpdater) => TState
onSubscribe?: (
listener: Listener,
store: Store<TState, TUpdater>,
) => () => void
onUpdate?: () => void
}

export class Store<
TState,
TUpdater extends AnyUpdater = (cb: TState) => TState,
> {
listeners = new Set<Listener>()
state: TState
options?: StoreOptions<TState, TUpdater>
_batching = false
_flushing = 0

constructor(initialState: TState, options?: StoreOptions<TState, TUpdater>) {
this.state = initialState
this.options = options
}

subscribe = (listener: Listener) => {
this.listeners.add(listener)
const unsub = this.options?.onSubscribe?.(listener, this)
return () => {
this.listeners.delete(listener)
unsub?.()
}
}

setState = (updater: TUpdater) => {
const previous = this.state
this.state = this.options?.updateFn
? this.options.updateFn(previous)(updater)
: (updater as any)(previous)

// Always run onUpdate, regardless of batching
this.options?.onUpdate?.()

// Attempt to flush
this._flush()
}

_flush = () => {
if (this._batching) return
const flushId = ++this._flushing
this.listeners.forEach((listener) => {
if (this._flushing !== flushId) return
listener()
})
}

batch = (cb: () => void) => {
if (this._batching) return cb()
this._batching = true
cb()
this._batching = false
this._flush()
}
}
export * from './derived'
export * from './effect'
export * from './store'
export * from './types'
68 changes: 68 additions & 0 deletions packages/store/src/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { AnyUpdater, Listener } from './types'

interface StoreOptions<
TState,
TUpdater extends AnyUpdater = (cb: TState) => TState,
> {
updateFn?: (previous: TState) => (updater: TUpdater) => TState
onSubscribe?: (
listener: Listener,
store: Store<TState, TUpdater>,
) => () => void
onUpdate?: () => void
}

export class Store<
TState,
TUpdater extends AnyUpdater = (cb: TState) => TState,
> {
listeners = new Set<Listener>()
state: TState
options?: StoreOptions<TState, TUpdater>
_batching = false
_flushing = 0

constructor(initialState: TState, options?: StoreOptions<TState, TUpdater>) {
this.state = initialState
this.options = options
}

subscribe = (listener: Listener) => {
this.listeners.add(listener)
const unsub = this.options?.onSubscribe?.(listener, this)
return () => {
this.listeners.delete(listener)
unsub?.()
}
}

setState = (updater: TUpdater) => {
const previous = this.state
this.state = this.options?.updateFn
? this.options.updateFn(previous)(updater)
: (updater as any)(previous)

// Always run onUpdate, regardless of batching
this.options?.onUpdate?.()

// Attempt to flush
this._flush()
}

_flush = () => {
if (this._batching) return
const flushId = ++this._flushing
for (const listener of this.listeners) {
if (this._flushing !== flushId) continue
listener()
}
}

batch = (cb: () => void) => {
if (this._batching) return cb()
this._batching = true
cb()
this._batching = false
this._flush()
}
}