diff --git a/packages/context-state/src/create-root.ts b/packages/context-state/src/create-root.ts new file mode 100644 index 0000000..a4ab750 --- /dev/null +++ b/packages/context-state/src/create-root.ts @@ -0,0 +1,35 @@ +import { children } from "./internal" +import { StateNode } from "./types" + +export interface RootNode extends StateNode { + run(): () => void +} + +export function createRoot(): RootNode { + const childRunners = new Set<(isActive: boolean, value: null) => void>() + const runChildren = (isActive: boolean) => { + childRunners.forEach((cb) => { + cb(isActive, null) + }) + } + + const result: RootNode = { + getValue: () => { + throw new Error("RootNode doesn't have value") + }, + state$: () => { + throw new Error("RootNode doesn't have value") + }, + run: () => { + // Maybe more fancy with refcount, etc? + runChildren(true) + return () => { + runChildren(false) + } + }, + } + + children.set(result, childRunners) + + return result +} diff --git a/packages/context-state/src/internal/children.ts b/packages/context-state/src/internal/children.ts new file mode 100644 index 0000000..95909de --- /dev/null +++ b/packages/context-state/src/internal/children.ts @@ -0,0 +1,6 @@ +import { StateNode } from "../types" + +export const children = new WeakMap< + StateNode, + Set<(isActive: boolean, value: any) => void> +>() diff --git a/packages/context-state/src/internal/index.ts b/packages/context-state/src/internal/index.ts index 9a38cbf..4c25764 100644 --- a/packages/context-state/src/internal/index.ts +++ b/packages/context-state/src/internal/index.ts @@ -1,3 +1,4 @@ export * from "./empty-value" export * from "./errors" export * from "./promisses" +export * from "./children" diff --git a/packages/context-state/src/substate.test.ts b/packages/context-state/src/substate.test.ts new file mode 100644 index 0000000..cfaffea --- /dev/null +++ b/packages/context-state/src/substate.test.ts @@ -0,0 +1,224 @@ +import { EMPTY, NEVER, Observable, of, Subject, throwError } from "rxjs" +import { createRoot } from "./create-root" +import { substate } from "./substate" + +describe("subState", () => { + describe("constructor", () => { + it("subscribes to the inner observable when the parent has a value", () => { + const root = createRoot() + const contextSource$ = new Subject() + const contextNode = substate(root, () => contextSource$) + + let ranFunction = false + substate(contextNode, () => { + ranFunction = true + return EMPTY + }) + + expect(ranFunction).toBe(false) + + root.run() + expect(ranFunction).toBe(false) + + contextSource$.next(1) + expect(ranFunction).toBe(true) + }) + + it("unsubscribes from the previous observable before subscribing to the new one when the context changes", () => { + const root = createRoot() + const contextSource$ = new Subject() + const contextNode = substate(root, () => contextSource$) + + let subscribed = false, + unsubscribed = false + substate(contextNode, () => { + return new Observable(() => { + subscribed = true + + return () => { + // the test will reset `subscribed` to false when this function should be run. + expect(subscribed).toBe(false) + unsubscribed = true + } + }) + }) + + root.run() + contextSource$.next(1) + expect(subscribed).toBe(true) + expect(unsubscribed).toBe(false) + + subscribed = false + contextSource$.next(2) + expect(subscribed).toBe(true) + expect(unsubscribed).toBe(true) + }) + + it("can access any observable from the context with the ctx function", () => { + const root = createRoot() + const contextSource$ = new Subject() + const contextNode = substate(root, () => contextSource$) + + let lastContextValue: number | null = null + substate(contextNode, (ctx) => { + expect(ctx(contextNode)).toBe(lastContextValue) + return EMPTY + }) + + root.run() + expect.assertions(3) + contextSource$.next((lastContextValue = 1)) + contextSource$.next((lastContextValue = 2)) + contextSource$.next((lastContextValue = 3)) + }) + }) + + describe("getValue", () => { + it("throws when the node is not active", () => { + const root = createRoot() + const subNode = substate(root, () => of(1)) + + expect(() => subNode.getValue()).toThrowError("Inactive Context") + }) + + it("after an error it throws an InactiveContextError", () => { + const root = createRoot() + const error = new Error() + const subNode = substate(root, () => throwError(() => error)) + root.run() + + expect(() => { + console.log(subNode.getValue()) + }).toThrowError("Inactive Context") + }) + + it("returns the latest value if the observable has already emitted", () => { + const source$ = new Subject() + const root = createRoot() + const subNode = substate(root, () => source$) + root.run() + + source$.next(1) + source$.next(2) + source$.next(3) + + expect(subNode.getValue()).toBe(3) + }) + + it("returns a promise that resolves when the first value is emitted", async () => { + const source$ = new Subject() + const root = createRoot() + const subNode = substate(root, () => source$) + source$.next(1) + root.run() + + const promise = subNode.getValue() + + source$.next(2) + source$.next(3) + + await expect(promise).resolves.toBe(2) + }) + + it("rejects the promise if the node becomes inactive", async () => { + const root = createRoot() + const subNode = substate(root, () => NEVER) + const stop = root.run() + + const promise = subNode.getValue() + + stop() + + await expect(promise).rejects.toBeTruthy() + }) + + it("rejects the promise when the observable emits an error", async () => { + const source$ = new Subject() + const root = createRoot() + const subNode = substate(root, () => source$) + root.run() + + const promise = subNode.getValue() + + const error = new Error() + source$.error(error) + + await expect(promise).rejects.toBe(error) + }) + + it("ignores the observable until its context has a value", async () => { + const root = createRoot() + const contextSource$ = new Subject() + const contextNode = substate(root, () => contextSource$) + const source$ = new Subject() + const subNode = substate(contextNode, () => source$) + root.run() + + source$.next(1) + + const promise = subNode.getValue() + expect(promise).toBeInstanceOf(Promise) + + source$.next(2) + source$.next(3) + + contextSource$.next(1) + + source$.next(4) + + await expect(promise).resolves.toBe(4) + }) + + it("discards the old value after a context changes, returning a new promise", async () => { + const root = createRoot() + const contextSource$ = new Subject() + const contextNode = substate(root, () => contextSource$) + const source$ = new Subject() + const subNode = substate(contextNode, () => source$) + root.run() + + contextSource$.next(1) + source$.next(2) + expect(subNode.getValue()).toBe(2) + + contextSource$.next(3) + const promise = subNode.getValue() + + source$.next(4) + await expect(promise).resolves.toBe(4) + }) + + it("returns a promise that yields the value after a context has changed", async () => { + const root = createRoot() + const contextSource$ = new Subject() + const contextNode = substate(root, () => contextSource$) + const subNode = substate(contextNode, (ctx) => + ctx(contextNode) === 2 ? of("done!") : EMPTY, + ) + root.run() + + const promise = subNode.getValue() + + contextSource$.next(1) + contextSource$.next(2) + + await expect(promise).resolves.toBe("done!") + }) + + it("rejects the promise if any of its context emits an error", async () => { + const source$ = new Subject() + const root = createRoot() + const subNode = substate(root, () => source$) + root.run() + + const promise = subNode.getValue() + + const error = new Error() + source$.error(error) + + await expect(promise).rejects.toBe(error) + }) + }) + + describe("state$", () => {}) +}) diff --git a/packages/context-state/src/substate.ts b/packages/context-state/src/substate.ts index f8424da..f6e85fb 100644 --- a/packages/context-state/src/substate.ts +++ b/packages/context-state/src/substate.ts @@ -7,13 +7,9 @@ import { StatePromise, DeferredPromise, createDeferredPromise, + children, } from "./internal" -const children = new WeakMap< - StateNode, - Set<(isActive: boolean, value: any) => void> ->() - export const ctx = (node: StateNode): V => { const value = node.getValue() if (value instanceof StatePromise) throw invalidContext() @@ -24,7 +20,7 @@ export type Ctx = typeof ctx export const substate = ( parent: StateNode

, getState$: (ctx: Ctx) => Observable, - equalityFn: (a: T, b: T) => boolean, + equalityFn: (a: T, b: T) => boolean = Object.is, ): StateNode => { let subject: ReplaySubject | null = null let subscription: Subscription | null = null