diff --git a/packages/context-state2/src/create-root.ts b/packages/context-state2/src/create-root.ts index 5ed69d5..0a6126d 100644 --- a/packages/context-state2/src/create-root.ts +++ b/packages/context-state2/src/create-root.ts @@ -15,11 +15,11 @@ export function createRoot( export function createRoot( keyName?: KeyName, ): RootNode { - const internalNode = createStateNode< - null, - RootNodeKey, - null - >(keyName ? [keyName] : [], [], () => of(null)) + const internalNode = createStateNode, null>( + keyName ? [keyName] : [], + [], + () => of(null), + ) internalNode.public.getState$ = () => { throw new Error("RootNode doesn't have value") diff --git a/packages/context-state2/src/index.tsx b/packages/context-state2/src/index.tsx index 5fe6acc..c1665c9 100644 --- a/packages/context-state2/src/index.tsx +++ b/packages/context-state2/src/index.tsx @@ -1,5 +1,6 @@ export * from "./create-root" export * from "./route-state" +export * from "./subinstance" export * from "./substate" export * from "./types" export { StatePromise } from "./internal/promises" diff --git a/packages/context-state2/src/internal/state-node.ts b/packages/context-state2/src/internal/state-node.ts index 1025470..f123b0e 100644 --- a/packages/context-state2/src/internal/state-node.ts +++ b/packages/context-state2/src/internal/state-node.ts @@ -47,9 +47,9 @@ interface GetObservableFn { ): Observable } -export function createStateNode( +export function createStateNode( keysOrder: Array, - parents: Array>, + parents: Array>, instanceCreator: ( getContext: (node: InternalStateNode) => R, getObservable: GetObservableFn, @@ -67,7 +67,7 @@ export function createStateNode( } const getContext = ( - otherNode: InternalStateNode, + otherNode: InternalStateNode, key: K, visited = new Set>(), ) => { diff --git a/packages/context-state2/src/subinstance.test.ts b/packages/context-state2/src/subinstance.test.ts new file mode 100644 index 0000000..47347b5 --- /dev/null +++ b/packages/context-state2/src/subinstance.test.ts @@ -0,0 +1,55 @@ +import { Subject, filter, map, startWith } from "rxjs" +import { InstanceUpdate, createRoot, subinstance } from "./" + +describe("subinstance", () => { + it("works", () => { + const root = createRoot() + const instance$ = new Subject>() + const updates$ = new Subject<{ key: string; value: string }>() + const instanceNode = subinstance( + root, + "keyName", + () => instance$, + (id) => + updates$.pipe( + filter((v) => v.key === id), + map((v) => v.value), + startWith(id), + ), + ) + root.run() + + instance$.next({ + type: "add", + key: "a", + }) + expect(instanceNode.getValue({ keyName: "a" })).toEqual("a") + + instance$.next({ + type: "add", + key: "b", + }) + expect(instanceNode.getValue({ keyName: "a" })).toEqual("a") + expect(instanceNode.getValue({ keyName: "b" })).toEqual("b") + + updates$.next({ + key: "a", + value: "new A value", + }) + expect(instanceNode.getValue({ keyName: "a" })).toEqual("new A value") + expect(instanceNode.getValue({ keyName: "b" })).toEqual("b") + + instance$.next({ + type: "remove", + key: "a", + }) + expect(() => instanceNode.getValue({ keyName: "a" })).toThrow() + expect(instanceNode.getValue({ keyName: "b" })).toEqual("b") + + instance$.next({ + type: "remove", + key: "b", + }) + expect(() => instanceNode.getValue({ keyName: "b" })).toThrow() + }) +}) diff --git a/packages/context-state2/src/subinstance.ts b/packages/context-state2/src/subinstance.ts new file mode 100644 index 0000000..5886c60 --- /dev/null +++ b/packages/context-state2/src/subinstance.ts @@ -0,0 +1,149 @@ +import { Observable, map, scan, startWith } from "rxjs" +import { NestedMap, createStateNode, getInternals } from "./internal" +import { substate } from "./substate" +import { + CtxFn, + GetObservableFn, + GetValueFn, + KeysBaseType, + StateNode, + isSignal, +} from "./types" + +export interface InstanceUpdate { + type: "add" | "remove" + key: K +} + +type MergeKey = { + [key in keyof K | KN]: (K & Record)[key] +} + +export type InstanceCtxFn = ( + id: Id, + ctxValue: GetValueFn, + ctxObservable: GetObservableFn, + key: K, +) => Observable + +export function subinstance< + T, + K extends KeysBaseType, + KN extends string, + KV, + R, +>( + parent: StateNode, + keyName: KN, + keySelector: CtxFn, K>, + instanceObs: InstanceCtxFn, KV>, +): StateNode> { + const instanceKeys = substate( + parent, + (ctx, getObs, key) => { + const keys = Object.assign(new Set(), { + lastUpdate: null, + } as { + lastUpdate: InstanceUpdate | null + }) + return keySelector(ctx, getObs, key).pipe( + scan((acc, change) => { + acc.lastUpdate = change + if (change.type === "add") { + acc.add(change.key) + } else { + acc.delete(change.key) + } + return acc + }, keys), + startWith(keys), + ) + }, + () => false, + ) + + const parentInternals = getInternals(parent) + const result = createStateNode, R>( + [...parentInternals.keysOrder, keyName], + [parentInternals], + (ctx, obs, key) => + // TODO common pattern, mapping the CtxFn from internal to external + instanceObs( + key[keyName], + (node) => ctx(getInternals(node)), + ((node, keys) => + obs( + isSignal(node) ? node : getInternals(node), + keys, + )) as GetObservableFn>, + key, + ), + ) + + const parentInstanceWatches = new NestedMap void>() + function watchParentInstance(key: K) { + const sub = instanceKeys + .getState$(key) + .pipe(map((v, i) => [v, i] as const)) + .subscribe(([v, i]) => { + if (i === 0) { + for (let instanceKey of v) { + result.addInstance({ + ...key, + [keyName]: instanceKey, + }) + } + for (let instanceKey of v) { + result.activateInstance({ + ...key, + [keyName]: instanceKey, + }) + } + } else { + if (v.lastUpdate?.type === "add") { + result.addInstance({ + ...key, + [keyName]: v.lastUpdate.key, + }) + result.activateInstance({ + ...key, + [keyName]: v.lastUpdate.key, + }) + } else if (v.lastUpdate?.type === "remove") { + result.removeInstance({ + ...key, + [keyName]: v.lastUpdate.key, + }) + } + } + }) + + parentInstanceWatches.set( + parentInternals.keysOrder.map((k) => key[k]), + () => sub.unsubscribe(), + ) + } + function stopWatchParentInstance(key: K) { + const orderedKey = parentInternals.keysOrder.map((k) => key[k]) + const teardown = parentInstanceWatches.get(orderedKey) + parentInstanceWatches.delete(orderedKey) + teardown?.() + } + + // TODO this pattern is common on all operators... maybe it can be abstracted away? watch parent instances, do something on them, tear down their subscriptions. + for (let instance of parentInternals.getInstances()) { + watchParentInstance(instance.key) + } + + parentInternals.instanceChange$.subscribe((change) => { + if (change.type === "added") { + watchParentInstance(change.key) + } else if (change.type === "removed") { + stopWatchParentInstance(change.key) + } + }) + + return Object.assign(result.public, { + instanceKeys, + }) +} diff --git a/packages/context-state2/src/types.ts b/packages/context-state2/src/types.ts index 17d6a95..a792436 100644 --- a/packages/context-state2/src/types.ts +++ b/packages/context-state2/src/types.ts @@ -27,7 +27,7 @@ export function isSignal( ) } -interface GetObservableFn { +export interface GetObservableFn { ( other: K extends CK ? StateNode | Signal : never, ): Observable