From c962741c82ce6625cb3a72c7232e9dd52f2f4fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Oliva?= Date: Sun, 25 Jun 2023 20:07:37 +0200 Subject: [PATCH] alternative implementation of internals --- package-lock.json | 27 +- packages/context-state2/README.md | 7 + packages/context-state2/babel.config.js | 21 + packages/context-state2/jest.config.js | 16 + packages/context-state2/package.json | 58 ++ packages/context-state2/setupTests.ts | 5 + packages/context-state2/src/create-root.ts | 52 ++ packages/context-state2/src/index.tsx | 5 + .../src/internal/empty-value.ts | 2 + .../context-state2/src/internal/errors.ts | 16 + packages/context-state2/src/internal/index.ts | 8 + .../context-state2/src/internal/internals.ts | 28 + .../context-state2/src/internal/nested-map.ts | 76 +++ .../context-state2/src/internal/promises.ts | 18 + .../src/internal/record-utils.ts | 18 + .../src/internal/state-instance.ts | 124 ++++ .../context-state2/src/internal/state-node.ts | 181 ++++++ .../context-state2/src/route-state.test.ts | 190 ++++++ packages/context-state2/src/route-state.ts | 132 +++++ packages/context-state2/src/substate.test.ts | 561 ++++++++++++++++++ packages/context-state2/src/substate.ts | 79 +++ .../src/test-utils/finalizationRegistry.ts | 38 ++ packages/context-state2/src/types.ts | 31 + packages/context-state2/tsconfig-build.json | 4 + packages/context-state2/tsconfig.json | 30 + 25 files changed, 1726 insertions(+), 1 deletion(-) create mode 100644 packages/context-state2/README.md create mode 100644 packages/context-state2/babel.config.js create mode 100644 packages/context-state2/jest.config.js create mode 100644 packages/context-state2/package.json create mode 100644 packages/context-state2/setupTests.ts create mode 100644 packages/context-state2/src/create-root.ts create mode 100644 packages/context-state2/src/index.tsx create mode 100644 packages/context-state2/src/internal/empty-value.ts create mode 100644 packages/context-state2/src/internal/errors.ts create mode 100644 packages/context-state2/src/internal/index.ts create mode 100644 packages/context-state2/src/internal/internals.ts create mode 100644 packages/context-state2/src/internal/nested-map.ts create mode 100644 packages/context-state2/src/internal/promises.ts create mode 100644 packages/context-state2/src/internal/record-utils.ts create mode 100644 packages/context-state2/src/internal/state-instance.ts create mode 100644 packages/context-state2/src/internal/state-node.ts create mode 100644 packages/context-state2/src/route-state.test.ts create mode 100644 packages/context-state2/src/route-state.ts create mode 100644 packages/context-state2/src/substate.test.ts create mode 100644 packages/context-state2/src/substate.ts create mode 100644 packages/context-state2/src/test-utils/finalizationRegistry.ts create mode 100644 packages/context-state2/src/types.ts create mode 100644 packages/context-state2/tsconfig-build.json create mode 100644 packages/context-state2/tsconfig.json diff --git a/package-lock.json b/package-lock.json index b116f95..382f278 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "react-rxjs", + "name": "re-rxjs", "lockfileVersion": 2, "requires": true, "packages": { @@ -2352,6 +2352,10 @@ "resolved": "packages/context-state", "link": true }, + "node_modules/@react-rxjs/context-state2": { + "resolved": "packages/context-state2", + "link": true + }, "node_modules/@react-rxjs/core": { "resolved": "packages/core", "link": true @@ -8664,6 +8668,20 @@ "rxjs": ">=7" } }, + "packages/context-state2": { + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.0.0" + }, + "devDependencies": { + "@types/use-sync-external-store": "^0.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0", + "rxjs": ">=7" + } + }, "packages/core": { "name": "@react-rxjs/core", "version": "0.10.3", @@ -10367,6 +10385,13 @@ "use-sync-external-store": "^1.0.0" } }, + "@react-rxjs/context-state2": { + "version": "file:packages/context-state2", + "requires": { + "@types/use-sync-external-store": "^0.0.3", + "use-sync-external-store": "^1.0.0" + } + }, "@react-rxjs/core": { "version": "file:packages/core", "requires": { diff --git a/packages/context-state2/README.md b/packages/context-state2/README.md new file mode 100644 index 0000000..5d78b6a --- /dev/null +++ b/packages/context-state2/README.md @@ -0,0 +1,7 @@ +# @react-rxjs/core + +Please visit the website: https://react-rxjs.org + +## Installation + + npm install @react-rxjs/core diff --git a/packages/context-state2/babel.config.js b/packages/context-state2/babel.config.js new file mode 100644 index 0000000..b05f257 --- /dev/null +++ b/packages/context-state2/babel.config.js @@ -0,0 +1,21 @@ +// Only used by Jest +module.exports = { + presets: [ + [ + "@babel/preset-env", + { useBuiltIns: "entry", corejs: "2", targets: { node: "current" } }, + ], + "@babel/preset-typescript", + ], + plugins: [ + function () { + return { + visitor: { + MetaProperty(path) { + path.replaceWithSourceString("process") + }, + }, + } + }, + ], +} diff --git a/packages/context-state2/jest.config.js b/packages/context-state2/jest.config.js new file mode 100644 index 0000000..8aa6925 --- /dev/null +++ b/packages/context-state2/jest.config.js @@ -0,0 +1,16 @@ +module.exports = { + preset: "ts-jest", + testEnvironment: "jsdom", + moduleNameMapper: { + "^@/(.*)$": "/src/$1", + }, + setupFilesAfterEnv: ["/setupTests.ts"], + globals: { + "ts-jest": { + babelConfig: true, + diagnostics: { + warnOnly: true, + }, + }, + }, +} diff --git a/packages/context-state2/package.json b/packages/context-state2/package.json new file mode 100644 index 0000000..b55449f --- /dev/null +++ b/packages/context-state2/package.json @@ -0,0 +1,58 @@ +{ + "version": "0.0.1", + "repository": { + "type": "git", + "url": "git+https://github.com/re-rxjs/react-rxjs.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "node": { + "module": "./dist/context-state.es2017.js", + "import": "./dist/context-state.es2019.mjs", + "require": "./dist/index.cjs" + }, + "default": "./dist/context-state.es2017.js" + }, + "./package.json": "./package.json" + }, + "module": "./dist/context-state.es2017.js", + "main": "./dist/index.js", + "typings": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "npm run build:ts && npm run build:esm2017 && npm run build:esm2019", + "build:esm2019": "esbuild src/index.tsx --bundle --outfile=./dist/context-state.es2019.mjs --target=es2019 --external:react --external:rxjs --external:use-sync-external-store --format=esm --sourcemap", + "build:esm2017": "esbuild src/index.tsx --bundle --outfile=./dist/context-state.es2017.js --target=es2017 --external:react --external:rxjs --external:use-sync-external-store --format=esm --sourcemap", + "build:cjs:dev": "node cjsBuild.js", + "build:cjs:prod": "node cjsBuild.js --prod", + "build:ts": "tsc -p ./tsconfig-build.json --outDir ./dist --skipLibCheck --emitDeclarationOnly", + "test": "jest --coverage", + "lint": "prettier --check README.md \"src/**/*.{js,jsx,ts,tsx,json,md}\"", + "format": "prettier --write README.md \"src/**/*.{js,jsx,ts,tsx,json,md}\"", + "prepack": "npm run build" + }, + "peerDependencies": { + "react": ">=16.8.0", + "rxjs": ">=7" + }, + "prettier": { + "printWidth": 80, + "semi": false, + "trailingComma": "all" + }, + "name": "@react-rxjs/context-state2", + "authors": [ + "Josep M Sobrepere (https://github.com/josepot)", + "Victor Oliva (https://github.com/voliva)" + ], + "dependencies": { + "use-sync-external-store": "^1.0.0" + }, + "devDependencies": { + "@types/use-sync-external-store": "^0.0.3" + } +} diff --git a/packages/context-state2/setupTests.ts b/packages/context-state2/setupTests.ts new file mode 100644 index 0000000..6a0fd12 --- /dev/null +++ b/packages/context-state2/setupTests.ts @@ -0,0 +1,5 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import "@testing-library/jest-dom" diff --git a/packages/context-state2/src/create-root.ts b/packages/context-state2/src/create-root.ts new file mode 100644 index 0000000..a23ff23 --- /dev/null +++ b/packages/context-state2/src/create-root.ts @@ -0,0 +1,52 @@ +import { of } from "rxjs" +import { createStateNode } from "./internal" +import { StateNode } from "./types" + +type RootNodeKey = K extends "" ? {} : Record +export interface RootNode + extends StateNode> { + run: K extends "" ? () => () => void : (key: V) => () => void +} + +export function createRoot(): RootNode +export function createRoot( + keyName: KeyName, +): RootNode +export function createRoot( + keyName?: KeyName, +): RootNode { + const internalNode = createStateNode< + null, + RootNodeKey, + null + >(keyName ? [keyName] : [], null, () => of(null)) + + internalNode.public.getState$ = () => { + throw new Error("RootNode doesn't have value") + } + internalNode.public.getValue = () => { + throw new Error("RootNode doesn't have value") + } + + const result: RootNode = Object.assign( + internalNode.public as any, + { + run: (root?: KeyValue) => { + const key = ( + keyName + ? { + [keyName]: root, + } + : {} + ) as RootNodeKey + + internalNode.addInstance(key) + internalNode.activateInstance(key) + return () => { + internalNode.removeInstance(key) + } + }, + }, + ) + return result +} diff --git a/packages/context-state2/src/index.tsx b/packages/context-state2/src/index.tsx new file mode 100644 index 0000000..5fe6acc --- /dev/null +++ b/packages/context-state2/src/index.tsx @@ -0,0 +1,5 @@ +export * from "./create-root" +export * from "./route-state" +export * from "./substate" +export * from "./types" +export { StatePromise } from "./internal/promises" diff --git a/packages/context-state2/src/internal/empty-value.ts b/packages/context-state2/src/internal/empty-value.ts new file mode 100644 index 0000000..bef3d23 --- /dev/null +++ b/packages/context-state2/src/internal/empty-value.ts @@ -0,0 +1,2 @@ +export const EMPTY_VALUE = Symbol("empty") +export type EMPTY_VALUE = typeof EMPTY_VALUE diff --git a/packages/context-state2/src/internal/errors.ts b/packages/context-state2/src/internal/errors.ts new file mode 100644 index 0000000..faffe14 --- /dev/null +++ b/packages/context-state2/src/internal/errors.ts @@ -0,0 +1,16 @@ +class InactiveContextError extends Error { + constructor() { + super("Inactive Context") + this.name = "InactiveContextError" + } +} + +class InvalidContext extends Error { + constructor() { + super("Invalid Context") + this.name = "InvalidContext" + } +} + +export const inactiveContext = () => new InactiveContextError() +export const invalidContext = () => new InvalidContext() diff --git a/packages/context-state2/src/internal/index.ts b/packages/context-state2/src/internal/index.ts new file mode 100644 index 0000000..f94f3f6 --- /dev/null +++ b/packages/context-state2/src/internal/index.ts @@ -0,0 +1,8 @@ +export * from "./empty-value" +export * from "./errors" +export * from "./promises" +export * from "./record-utils" +export * from "./nested-map" +export * from "./internals" +export * from "./state-node" +export * from "./state-instance" diff --git a/packages/context-state2/src/internal/internals.ts b/packages/context-state2/src/internal/internals.ts new file mode 100644 index 0000000..0d9f698 --- /dev/null +++ b/packages/context-state2/src/internal/internals.ts @@ -0,0 +1,28 @@ +import { defer } from "rxjs" +import { StateNode } from "../types" +import { InternalStateNode, KeysBaseType } from "./state-node" + +const internals = new WeakMap< + StateNode, + InternalStateNode +>() + +export const getInternals = ( + node: StateNode, +): InternalStateNode => internals.get(node)! + +export function linkPublicInterface( + internal: InternalStateNode, +): StateNode { + if (internal.public) { + return internal.public + } + const node: StateNode = { + getState$: (key?: K) => + defer(() => internal.getInstance(key ?? ({} as K)).getState$()), + getValue: (key?: K) => internal.getInstance(key ?? ({} as K)).getValue(), + } + internals.set(node, internal) + internal.public = node + return node +} diff --git a/packages/context-state2/src/internal/nested-map.ts b/packages/context-state2/src/internal/nested-map.ts new file mode 100644 index 0000000..0603ea6 --- /dev/null +++ b/packages/context-state2/src/internal/nested-map.ts @@ -0,0 +1,76 @@ +export class NestedMap { + private root: Map + private rootValue?: V + constructor() { + this.root = new Map() + this.rootValue = undefined + } + + get(keys: K[]): V | undefined { + if (keys.length === 0) return this.rootValue + let current: any = this.root + for (let i = 0; i < keys.length; i++) { + current = current.get(keys[i]) + if (!current) return undefined + // a child instance could be checking for a parent instance with its (longer) key + if (!(current instanceof Map)) return current + } + return current + } + + set(keys: K[], value: V): void { + if (keys.length === 0) { + this.rootValue = value + return + } + let current: Map = this.root + let i + for (i = 0; i < keys.length - 1; i++) { + let nextCurrent = current.get(keys[i]) + if (!nextCurrent) { + nextCurrent = new Map() + current.set(keys[i], nextCurrent) + } + current = nextCurrent + } + current.set(keys[i], value) + } + + delete(keys: K[]): void { + if (keys.length === 0) { + delete this.rootValue + return + } + const maps: Map[] = [this.root] + let current: Map = this.root + + for (let i = 0; i < keys.length - 1; i++) { + maps.push((current = current.get(keys[i]))) + } + + let mapIdx = maps.length - 1 + maps[mapIdx].delete(keys[mapIdx]) + + while (--mapIdx > -1 && maps[mapIdx].get(keys[mapIdx]).size === 0) { + maps[mapIdx].delete(keys[mapIdx]) + } + } + + *values(): Generator { + if (this.rootValue) { + yield this.rootValue + } + + const mapsToIterate: Array> = [this.root] + let map: Map | undefined + while ((map = mapsToIterate.pop())) { + for (let [_, value] of map) { + if (value instanceof Map) { + mapsToIterate.push(value) + } else { + yield value + } + } + } + } +} diff --git a/packages/context-state2/src/internal/promises.ts b/packages/context-state2/src/internal/promises.ts new file mode 100644 index 0000000..6109b88 --- /dev/null +++ b/packages/context-state2/src/internal/promises.ts @@ -0,0 +1,18 @@ +export class StatePromise extends Promise {} + +export interface DeferredPromise { + promise: StatePromise + res: (value: T) => void + rej: (err: any) => void +} + +export const createDeferredPromise = (): DeferredPromise => { + let res: (value: T) => void + let rej: (err: any) => void + const promise = new StatePromise((resolve, reject) => { + res = resolve + rej = reject + }) + + return { promise, res: res!, rej: rej! } +} diff --git a/packages/context-state2/src/internal/record-utils.ts b/packages/context-state2/src/internal/record-utils.ts new file mode 100644 index 0000000..d72852c --- /dev/null +++ b/packages/context-state2/src/internal/record-utils.ts @@ -0,0 +1,18 @@ +type RecordKeys = (o: Record) => K[] +export const recordKeys: RecordKeys = Object.keys + +type RecordEntries = (o: Record) => [K, T][] +export const recordEntries: RecordEntries = Object.entries + +type RecordFromEntries = ( + input: [K, T][], +) => Record +export const recordFromEntries: RecordFromEntries = Object.fromEntries + +export const mapRecord = ( + data: Record, + mapper: (x: T, i: K, o: Record) => TT, +): Record => + recordFromEntries( + recordEntries(data).map(([k, v]) => [k, mapper(v, k, data)]), + ) diff --git a/packages/context-state2/src/internal/state-instance.ts b/packages/context-state2/src/internal/state-instance.ts new file mode 100644 index 0000000..a2fd02a --- /dev/null +++ b/packages/context-state2/src/internal/state-instance.ts @@ -0,0 +1,124 @@ +import { BehaviorSubject, Observable, Subscription, filter } from "rxjs" +import { EMPTY_VALUE } from "./empty-value" +import { StatePromise, createDeferredPromise } from "./promises" +import { KeysBaseType } from "./state-node" + +// AKA StateObservable +export interface Instance { + key: K + activate: () => void + kill: () => void + reset: () => void + getValue: () => T | StatePromise + getState$: () => Observable +} + +export function createInstance( + key: K, + observable: Observable, +): Instance { + let subject = new BehaviorSubject(EMPTY_VALUE) + + // TODO firehose + let deferred = createDeferredPromise() + // Don't mark promise rejection as unhandled (???) + deferred.promise.then( + () => {}, + () => {}, + ) + let error = EMPTY_VALUE + + let subscription: Subscription | null = null + const restart = () => { + subscription?.unsubscribe() + subscription = observable.subscribe({ + next: (v) => { + deferred.res(v) + subject.next(v) + }, + error: (e) => { + deferred.rej(e) + error = e + subject.error(e) + }, + complete: () => { + // TODO I thought this makes sense, but then a test fails! + // I think the test is wrong, instead of EMPTY it should map to NEVER + // I don't think leaving promises unfinished is something we wanted. + // if (subject.getValue() === EMPTY_VALUE) { + // deferred.rej("TODO What kind of error? Test doesn't say") + // } + }, + }) + } + + const instance: Instance = { + key, + activate() { + if (subscription) { + throw new Error("Instance already active") + } + + restart() + }, + kill() { + subscription?.unsubscribe() + subject.complete() + if (subject.getValue() === EMPTY_VALUE) { + deferred.rej("TODO What kind of error? Test doesn't say") + } + }, + reset() { + if (error !== EMPTY_VALUE || subject.getValue() !== EMPTY_VALUE) { + // If the new subscription returns the same value synchronously, do not complete the previous result. + // TODO the child nodes should also reset... are they resetting? + error = EMPTY_VALUE + deferred = createDeferredPromise() + subscription?.unsubscribe() + let isSynchronous = true + let passed = false + subscription = observable.subscribe({ + next: (v) => { + deferred.res(v) + // TODO equality function + if (isSynchronous && !Object.is(subject.getValue(), v)) { + const oldSubject = subject + subject = new BehaviorSubject(EMPTY_VALUE) + oldSubject.complete() + } + passed = true + subject.next(v) + }, + error: (e) => { + deferred.rej(e) + error = e + subject.error(e) + }, + }) + isSynchronous = false + + if (!passed) { + const oldSubject = subject + subject = new BehaviorSubject(EMPTY_VALUE) + oldSubject.complete() + } + } + restart() + }, + getValue() { + if (error !== EMPTY_VALUE) { + throw error + } + + const value = subject.getValue() + if (value === EMPTY_VALUE) { + return deferred.promise + } + return value + }, + getState$() { + return subject.pipe(filter((v) => v !== EMPTY_VALUE)) as Observable + }, + } + return instance +} diff --git a/packages/context-state2/src/internal/state-node.ts b/packages/context-state2/src/internal/state-node.ts new file mode 100644 index 0000000..42de4b8 --- /dev/null +++ b/packages/context-state2/src/internal/state-node.ts @@ -0,0 +1,181 @@ +import { + Observable, + Subject, + defer, + of, + switchMap, + take, + throwError, +} from "rxjs" +import { StateNode } from "../types" +import { inactiveContext, invalidContext } from "./errors" +import { linkPublicInterface } from "./internals" +import { NestedMap } from "./nested-map" +import { StatePromise } from "./promises" +import { Instance, createInstance } from "./state-instance" + +export type KeysBaseType = Record + +export interface InternalStateNode { + keysOrder: Array + getInstances: () => Iterable> + getInstance: (key: K) => Instance + addInstance: (key: K) => void + activateInstance: (key: K) => void + removeInstance: (key: K) => void + resetInstance: (key: K) => void + instanceChange$: Observable<{ + type: "added" | "ready" | "removed" + key: K + }> + getContext: (node: InternalStateNode, key: K) => TC + public: StateNode +} + +interface GetObservableFn { + ( + other: K extends CK ? InternalStateNode : never, + ): Observable + ( + other: InternalStateNode, + keys: Omit, + ): Observable +} + +export function createStateNode( + keysOrder: Array, + parent: InternalStateNode | null, + instanceCreator: ( + getContext: (node: InternalStateNode) => R, + getObservable: GetObservableFn, + key: K, + ) => Observable, +): InternalStateNode { + const instances = new NestedMap>() + const nestedMapKey = (key: K) => keysOrder.map((k) => key[k]) + const getInstance = (key: K) => { + const result = instances.get(nestedMapKey(key)) + if (!result) { + throw inactiveContext() + } + return result + } + + const getContext = (otherNode: InternalStateNode, key: K) => { + if ((otherNode as any) === node) { + const value = node.public.getValue(key) + if (value instanceof StatePromise) { + throw invalidContext() + } + return value as unknown as TC + } + if (!parent) { + // TODO shouldn't it be something like "node not a parent" or "invalidContext"? + throw inactiveContext() + } + return parent.getContext(otherNode, key) + } + const instanceChange$ = new Subject<{ + type: "added" | "ready" | "removed" + key: K + }>() + const addInstance = (key: K) => { + // Wait until parent has emitted a value + const parent$ = defer(() => { + if (!parent) return of(null) + const instance = parent.getInstance(key) + try { + if (instance.getValue() instanceof StatePromise) { + return instance.getState$().pipe(take(1)) + } + } catch (error) { + return throwError(() => error) + } + return of(null) + }) + + // TODO case key already has instance? + instances.set( + nestedMapKey(key), + createInstance( + key, + parent$.pipe( + switchMap(() => { + try { + return instanceCreator( + (otherNode) => getContext(otherNode, key), + (other, keys?) => { + const mergedKey = { + ...key, + ...(keys ?? {}), + } + return other.public.getState$(mergedKey as any) + }, + key, + ) + } catch (ex) { + return throwError(() => ex) + } + }), + ), + ), + ) + + // We need two phases to let instances wire up before getting activated + instanceChange$.next({ + type: "added", + key, + }) + instanceChange$.next({ + type: "ready", + key, + }) + } + const activateInstance = (key: K) => { + getInstance(key).activate() + } + const removeInstance = (key: K) => { + // TODO already deleted + const instance = instances.get(nestedMapKey(key))! + instance.kill() + instances.delete(nestedMapKey(key)) + instanceChange$.next({ + type: "removed", + key, + }) + } + const resetInstance = (key: K) => { + const instance = instances.get(nestedMapKey(key)) + if (!instance) { + // TODO is this a valid path? Maybe throw error instead! + addInstance(key) + activateInstance(key) + return + } + // Only kill + readd if it was already active. + // If it was waiting to become activated, everything that dangles from it will + // also be waiting, and any promise/Observable returned by it is still pending + // which is something we want to keep. + instance.reset() + // if (instance.isActive) { + // removeInstance(key) + // addInstance(key) + // activateInstance(key) + // } + } + + const node: InternalStateNode = { + keysOrder, + getInstances: () => instances.values(), + getInstance, + addInstance, + activateInstance, + removeInstance, + resetInstance, + instanceChange$, + getContext, + public: null as any, + } + linkPublicInterface(node) + return node +} diff --git a/packages/context-state2/src/route-state.test.ts b/packages/context-state2/src/route-state.test.ts new file mode 100644 index 0000000..edb0862 --- /dev/null +++ b/packages/context-state2/src/route-state.test.ts @@ -0,0 +1,190 @@ +import { Observable, of, Subject } from "rxjs" +import { createRoot } from "./create-root" +import { routeState } from "./route-state" +import { substate } from "./substate" + +describe("routeState", () => { + describe("constructor", () => { + it("requires a node with a value", () => { + const root = createRoot() + const [key] = routeState(root, { a: null }, () => "a") + root.run() + + expect(() => key.getValue()).toThrowError("RootNode doesn't have value") + }) + + it("passes the value of the node as a parameter for the selector function", () => { + const root = createRoot() + const parentSource = new Subject<"a" | "b">() + const parent = substate(root, () => parentSource) + const [key] = routeState(parent, { a: null, b: null }, (v) => v) + root.run() + + const promise = key.getValue() + expect(promise).toBeInstanceOf(Promise) + + parentSource.next("a") + expect(key.getValue()).toEqual("a") + + parentSource.next("b") + expect(key.getValue()).toEqual("b") + }) + + it("errors if the selector returns a key that doesn't exist", () => { + const root = createRoot() + const parent = substate(root, () => of("c")) + const [key] = routeState( + parent, + { a: null, b: null }, + (v) => v as "a" | "b", + ) + root.run() + + expect(() => key.getValue()).toThrow( + 'Invalid Route. Received "c" while valid keys are: "a, b"', + ) + }) + + it("errors if the selector throws an error", () => { + const root = createRoot() + const parent = substate(root, () => of("c")) + const [key] = routeState(parent, { a: null, b: null }, () => { + throw new Error("boom") + }) + root.run() + + expect(() => key.getValue()).toThrowError("boom") + }) + }) + + describe("activeKey", () => { + it("returns a node that will have the activeKey", () => { + const root = createRoot() + const parentSource = new Subject<"a" | "b">() + const parent = substate(root, () => parentSource) + const [key] = routeState(parent, { a: null, b: null }, (v) => v) + root.run() + + const next = jest.fn() + key.getState$().subscribe({ + next, + }) + + parentSource.next("a") + expect(key.getValue()).toEqual("a") + + parentSource.next("b") + expect(key.getValue()).toEqual("b") + + expect(next).toHaveBeenCalledTimes(2) + expect(next.mock.calls[0][0]).toEqual("a") + expect(next.mock.calls[1][0]).toEqual("b") + }) + + it("doesn't re-emit if the selector returns the same key after the parent changes", () => { + const root = createRoot() + const parentSource = new Subject() + const parent = substate(root, () => parentSource) + const [key] = routeState(parent, { a: null, b: null }, () => "a") + root.run() + + const next = jest.fn() + key.getState$().subscribe({ next }) + + parentSource.next(1) + parentSource.next(2) + + expect(next).toHaveBeenCalledTimes(1) + expect(next).toHaveBeenCalledWith("a") + }) + }) + + describe("nodes", () => { + it("creates as many nodes as routes, but activates only the one selected from selector", () => { + const root = createRoot() + const parentSource = new Subject<"a" | "b">() + const parent = substate(root, () => parentSource) + // Now I'd like to have routes first, then the key on the second place :'D + const [, routes] = routeState( + parent, + { + a: null, + b: null, + }, + (v) => v, + ) + root.run() + + expect(() => routes.a.getValue()).toThrowError("Inactive Context") + expect(() => routes.b.getValue()).toThrowError("Inactive Context") + + parentSource.next("a") + + expect(routes.a.getValue()).toEqual("a") + expect(() => routes.b.getValue()).toThrowError("Inactive Context") + }) + + it("deactivates the previous route before activating the new one", () => { + const root = createRoot() + const parentSource = new Subject<"a" | "b">() + const parent = substate(root, () => parentSource) + const [, { a, b }] = routeState( + parent, + { + a: null, + b: null, + }, + (v) => v, + ) + + const actions: string[] = [] + substate( + a, + () => + new Observable(() => { + actions.push("subscribe a") + return () => { + actions.push("unsubscribe a") + } + }), + ) + substate( + b, + () => + new Observable(() => { + actions.push("subscribe b") + return () => { + actions.push("unsubscribe b") + } + }), + ) + + root.run() + + parentSource.next("a") + parentSource.next("b") + + expect(actions).toEqual(["subscribe a", "unsubscribe a", "subscribe b"]) + }) + + it("uses the mapFn for each of the routes", () => { + const root = createRoot() + const parentSource = new Subject<"a" | "b">() + const parent = substate(root, () => parentSource) + const [, { a }] = routeState( + parent, + { + a: (v) => "a mapped " + v, + b: null, + }, + (v) => v, + ) + + root.run() + + parentSource.next("a") + + expect(a.getValue()).toEqual("a mapped a") + }) + }) +}) diff --git a/packages/context-state2/src/route-state.ts b/packages/context-state2/src/route-state.ts new file mode 100644 index 0000000..61bbe72 --- /dev/null +++ b/packages/context-state2/src/route-state.ts @@ -0,0 +1,132 @@ +import { Subscription, distinctUntilChanged, map, of } from "rxjs" +import { NestedMap, createStateNode, getInternals, mapRecord } from "./internal" +import { GetValueFn, StateNode, StringRecord } from "./types" + +export class InvalidRouteError extends Error { + constructor(key: string, keys: string[]) { + super( + `Invalid Route. Received "${key}" while valid keys are: "${keys.join( + ", ", + )}"`, + ) + this.name = "InvalidRouteError" + } +} + +export const routeState = < + T, + K extends StringRecord, + O extends StringRecord<((value: T) => any) | null>, + OT extends { + [KOT in keyof O]: null extends O[KOT] + ? StateNode + : O[KOT] extends (value: T) => infer V + ? StateNode + : unknown + }, +>( + parent: StateNode, + routes: O, + selector: (value: T, ctx: GetValueFn) => string & keyof O, +): [StateNode, OT] => { + const internalParent = getInternals(parent) + const keys = new Set(Object.keys(routes)) + const keyState = createKeyState(parent, keys, selector) + + const routedState = mapRecord(routes, (mapper) => { + return createStateNode(internalParent.keysOrder, internalParent, (ctx) => { + const parentValue = ctx(internalParent) + return of(mapper ? mapper(parentValue) : parentValue) + }) + }) + + const subscriptions = new NestedMap() + + const watchInstanceRoutes = (instanceKey: K) => { + let previousNode: any = null + const sub = keyState + .getInstance(instanceKey) + .getState$() + .subscribe({ + next: (activeKey) => { + if (previousNode) { + previousNode.removeInstance(instanceKey) + } + const node = routedState[activeKey] + node.addInstance(instanceKey) + node.activateInstance(instanceKey) + previousNode = node + }, + }) + const key = internalParent.keysOrder.map((k) => instanceKey[k]) + subscriptions.set(key, sub) + } + const reomveInstanceRoutes = (instanceKey: K) => { + const key = internalParent.keysOrder.map((k) => instanceKey[k]) + const sub = subscriptions.get(key) + subscriptions.delete(key) + sub?.unsubscribe() + } + + for (let instance of internalParent.getInstances()) { + watchInstanceRoutes(instance.key) + } + + internalParent.instanceChange$.subscribe((change) => { + if (change.type === "added") { + watchInstanceRoutes(change.key) + } else if (change.type === "removed") { + reomveInstanceRoutes(change.key) + } + }) + + return [keyState.public, mapRecord(routedState, (v) => v.public) as OT] +} + +const createKeyState = >( + parent: StateNode, + keys: Set, + selector: (value: T, ctx: GetValueFn) => string, +) => { + const internalParent = getInternals(parent) + const keyNode = createStateNode( + internalParent.keysOrder, + internalParent, + (ctx, _, key) => + parent.getState$(key).pipe( + map((value) => selector(value, (node) => ctx(getInternals(node)))), + map((key) => { + if (!keys.has(key)) throw new InvalidRouteError(key, [...keys]) + return key + }), + distinctUntilChanged(), + ), + ) + + const addInstance = (instanceKey: K) => { + // TODO duplicate ? + keyNode.addInstance(instanceKey) + } + const removeInstance = (instanceKey: K) => { + keyNode.removeInstance(instanceKey) + } + + for (let instance of internalParent.getInstances()) { + addInstance(instance.key) + } + for (let instance of internalParent.getInstances()) { + keyNode.activateInstance(instance.key) + } + + internalParent.instanceChange$.subscribe((change) => { + if (change.type === "added") { + addInstance(change.key) + } else if (change.type === "ready") { + keyNode.activateInstance(change.key) + } else if (change.type === "removed") { + removeInstance(change.key) + } + }) + + return keyNode +} diff --git a/packages/context-state2/src/substate.test.ts b/packages/context-state2/src/substate.test.ts new file mode 100644 index 0000000..ccb48bf --- /dev/null +++ b/packages/context-state2/src/substate.test.ts @@ -0,0 +1,561 @@ +import { + concat, + EMPTY, + from, + map, + NEVER, + Observable, + of, + Subject, + throwError, +} from "rxjs" +import { createRoot } from "./create-root" +import { routeState } from "./route-state" +import { substate } from "./substate" +import { testFinalizationRegistry } from "./test-utils/finalizationRegistry" + +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)) + }) + + it("throws an error when accessing a context that's invalid", () => { + const root = createRoot() + const [, { branchB }] = routeState( + substate(root, () => of("")), + { + branchA: null, + branchB: () => "b", + }, + () => "branchA", + ) + + substate(root, (ctx) => of(ctx(branchB))) + expect(() => root.run()).not.toThrow() + }) + + it("becomes unactive after throws an error for an invalid accessed context", () => { + const root = createRoot() + const contextSource = new Subject() + const contextNode = substate(root, () => contextSource) + const [, { branchB }] = routeState( + contextNode, + { + branchA: null, + branchB: () => "b", + }, + () => "branchA", + ) + + const subNode = substate(contextNode, (ctx) => of(ctx(branchB))) + + root.run() + + contextSource.next(1) + expect(() => contextNode.getValue({ root: "" })).not.toThrow() + expect(() => subNode.getValue({ root: "" })).toThrowError( + "Inactive Context", + ) + }) + }) + + describe("getValue", () => { + it("throws when the node is not active", () => { + const root = createRoot() + const subNode = substate(root, () => of(1)) + + expect(() => subNode.getValue({ root: "" })).toThrowError( + "Inactive Context", + ) + }) + + it("after an error it throws the error", () => { + const root = createRoot() + const error = new Error("boom!") + const subNode = substate(root, () => throwError(() => error)) + root.run() + + expect(() => { + console.log(subNode.getValue()) + }).toThrowError("boom!") + }) + + it("throws the parent error", () => { + const root = createRoot() + const error = new Error("boom!") + const subNode = substate(root, () => throwError(() => error)) + const subSubNode = substate(subNode, () => of(null)) + root.run() + + expect(() => { + console.log(subSubNode.getValue()) + }).toThrowError("boom!") + }) + + 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({ root: "" })).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({ root: "" }) + + 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({ root: "" }) + + 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({ root: "" }) + + 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({ root: "" }) + 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({ root: "" })).toBe(2) + + contextSource$.next(3) + const promise = subNode.getValue({ root: "" }) + + 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({ root: "" }) + + 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({ root: "" }) + + const error = new Error() + source$.error(error) + + await expect(promise).rejects.toBe(error) + }) + + it("can reference its siblings", () => { + const root = createRoot() + const nodeA = substate(root, (_, getState$) => + getState$(nodeB, {}).pipe(map((v) => v + "-a")), + ) + const nodeB = substate(root, () => of("b")) + + root.run() + + expect(nodeB.getValue()).toBe("b") + expect(nodeA.getValue()).toBe("b-a") + }) + }) + + describe("state$", () => { + it("emits the values that the inner observable emits", () => { + const root = createRoot() + const source$ = new Subject() + const subNode = substate(root, () => source$) + root.run() + + const emissions: number[] = [] + subNode.getState$({ root: "" }).subscribe({ + next: (v) => emissions.push(v), + }) + expect(emissions).toEqual([]) + + source$.next(1) + expect(emissions).toEqual([1]) + + source$.next(2) + expect(emissions).toEqual([1, 2]) + }) + + it("replays the latest value on late subscription", () => { + const root = createRoot() + const source$ = new Subject() + const subNode = substate(root, () => source$) + root.run() + + source$.next(1) + expect.assertions(1) + subNode.getState$({ root: "" }).subscribe({ + next: (v) => { + expect(v).toBe(1) + }, + }) + }) + + it("emits an error if the node is not active", () => { + const root = createRoot() + const source$ = new Subject() + const subNode = substate(root, () => source$) + + subNode.getState$({ root: "" }).subscribe({ + error: (e) => { + expect(e.message).toEqual("Inactive Context") + }, + }) + expect.assertions(1) + }) + + it("emits the error emitted by the inner observable", () => { + const root = createRoot() + const source$ = new Subject() + const subNode = substate(root, () => source$) + root.run() + + const error = new Error("haha") + subNode.getState$({ root: "" }).subscribe({ + error: (e) => expect(e).toBe(error), + }) + + expect.assertions(1) + source$.next(1) + source$.error(error) + }) + + it("doesn't propagate the complete of the inner observable", () => { + const root = createRoot() + const source$ = new Subject() + const subNode = substate(root, () => source$) + root.run() + + let completed = false + subNode.getState$({ root: "" }).subscribe({ + complete: () => (completed = true), + }) + + source$.complete() + expect(completed).toBe(false) + }) + + it("emits a complete when a context changes", () => { + const root = createRoot() + const contextSource$ = new Subject() + const contextNode = substate(root, () => contextSource$) + const source$ = new Subject() + const subNode = substate(contextNode, () => source$) + root.run() + + const complete = jest.fn() + subNode.getState$({ root: "" }).subscribe({ complete }) + + contextSource$.next(1) + expect(complete).not.toHaveBeenCalled() + + contextSource$.next(2) + expect(complete).not.toHaveBeenCalled() + + source$.next(1) + expect(complete).not.toHaveBeenCalled() + contextSource$.next(3) + expect(complete).toHaveBeenCalled() + }) + + it("doesn't emit the last value on resubscription after a complete", () => { + 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(1) + + contextSource$.next(2) + const next = jest.fn() + subNode.getState$({ root: "" }).subscribe({ next }) + + expect(next).not.toHaveBeenCalled() + }) + + it("doesn't emit a complete if a context emits without change", () => { + 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(1) + + const complete = jest.fn() + subNode.getState$({ root: "" }).subscribe({ complete }) + + contextSource$.next(1) + + expect(complete).not.toHaveBeenCalled() + }) + + it("doesn't emit a complete if after a context change the observable synchronously emits the same value", () => { + const root = createRoot() + const contextSource$ = new Subject() + const contextNode = substate(root, () => contextSource$) + const subNode = substate(contextNode, () => of(3)) + root.run() + + contextSource$.next(1) + + const complete = jest.fn() + subNode.getState$({ root: "" }).subscribe({ complete }) + + contextSource$.next(2) + + expect(complete).not.toHaveBeenCalled() + }) + + it("emits the values from the new context change even if the observable was created earlier", () => { + const root = createRoot() + const contextSource$ = new Subject() + const contextNode = substate(root, () => contextSource$) + const subNode = substate(contextNode, (ctx) => of(ctx(contextNode))) + root.run() + + contextSource$.next(1) + const observable = subNode.getState$({ root: "" }) + + contextSource$.next(2) + + expect.assertions(1) + observable.subscribe((v) => expect(v).toBe(2)) + }) + + // it("cleans up after self-referencing observables", () => { + // const root = createRoot() + // const signal = createSignal(root) + // const teardown = jest.fn() + // const nodeA = substate( + // root, + // (_, getState$) => + // new Observable((obs) => { + // const sub = getState$(signal) + // .pipe( + // withLatestFrom( + // defer(() => getState$(nodeA)).pipe(startWith("")), + // ), + // map(([val, prev]) => prev + val), + // ) + // .subscribe(obs) + + // return () => { + // sub.unsubscribe() + // teardown() + // } + // }), + // ) + // const stop = root.run() + + // signal.push("a") + // signal.push("b") + // signal.push("c") + // expect(nodeA.getValue()).toEqual("abc") + // expect(teardown).not.toBeCalled() + // stop() + // expect(teardown).toBeCalled() + // }) + + it("doesn't hold references to the observables that were created", async () => { + const fr = testFinalizationRegistry() + const root = createRoot() + + const nodeA = substate(root, () => + fr.tag("nodeA", concat(from([1, 2, 3]), NEVER)), + ) + const stop = root.run() + + expect(nodeA.getValue()).toEqual(3) + stop() + + await fr.assertFinalized("nodeA") + }) + + // it("doesn't hold references to dead instances, even on circular references", async () => { + // const fr = testFinalizationRegistry() + // const root = createRoot("gameId") + // const signal = createSignal(root) + + // const nodeA = substate( + // root, + // (_, getState$, { gameId }): Observable => + // fr.tag( + // "nodeA-" + gameId, + // getState$(signal).pipe( + // withLatestFrom(getState$(nodeB).pipe(startWith(""))), + // map(([, prev]) => prev + "/a/"), + // ), + // ), + // ) + // const nodeB = substate( + // root, + // (_, getState$, { gameId }): Observable => + // fr.tag( + // "nodeB-" + gameId, + // getState$(nodeA).pipe(map((v) => v + "$b$")), + // ), + // ) + + // root.run("b") + // const stopA = root.run("a") + + // signal.push({ gameId: "a" }, null) + // signal.push({ gameId: "a" }, null) + // signal.push({ gameId: "a" }, null) + // expect(nodeA.getValue({ gameId: "a" })).toEqual("/a/$b$/a/$b$/a/") + // stopA() + + // await fr.assertFinalized("nodeA-a") + // await fr.assertFinalized("nodeB-a") + // }) + }) +}) diff --git a/packages/context-state2/src/substate.ts b/packages/context-state2/src/substate.ts new file mode 100644 index 0000000..b80ec70 --- /dev/null +++ b/packages/context-state2/src/substate.ts @@ -0,0 +1,79 @@ +import { EMPTY, Subscription, defer, distinctUntilChanged, skip } from "rxjs" +import { NestedMap, createStateNode, getInternals } from "./internal" +import type { CtxFn, StateNode, StringRecord } from "./types" + +export const substate = >( + parent: StateNode, + getState$: CtxFn, + equalityFn: (a: T, b: T) => boolean = Object.is, +): StateNode => { + const internalParent = getInternals(parent) + const stateNode = createStateNode( + internalParent.keysOrder, + internalParent, + (getContext, getObservable, key) => + getState$( + (node) => getContext(getInternals(node)), + (other, keys?: any) => getObservable(getInternals(other), keys), + key, + ), + ) + + const subscriptions = new NestedMap() + + const addInstance = (instanceKey: P) => { + // TODO duplicate ? + stateNode.addInstance(instanceKey) + const sub = defer(() => { + try { + return parent.getState$(instanceKey) + } catch (ex) { + // root nodes don't have values, so they throw an error when trying to get the observable + return EMPTY + } + }) + .pipe(distinctUntilChanged(equalityFn), skip(1)) + .subscribe({ + next: () => { + stateNode.resetInstance(instanceKey) + // TODO shouldn't re-activation of instances happen after all subscribers have restarted? how to do it? + }, + error: () => { + // TODO + }, + complete: () => { + // ? + }, + }) + subscriptions.set( + internalParent.keysOrder.map((k) => instanceKey[k]), + sub, + ) + } + const removeInstance = (instanceKey: P) => { + const key = internalParent.keysOrder.map((k) => instanceKey[k]) + const sub = subscriptions.get(key) + subscriptions.delete(key) + sub?.unsubscribe() + stateNode.removeInstance(instanceKey) + } + + for (let instance of internalParent.getInstances()) { + addInstance(instance.key) + } + for (let instance of internalParent.getInstances()) { + stateNode.activateInstance(instance.key) + } + + internalParent.instanceChange$.subscribe((change) => { + if (change.type === "added") { + addInstance(change.key) + } else if (change.type === "ready") { + stateNode.activateInstance(change.key) + } else if (change.type === "removed") { + removeInstance(change.key) + } + }) + + return stateNode.public +} diff --git a/packages/context-state2/src/test-utils/finalizationRegistry.ts b/packages/context-state2/src/test-utils/finalizationRegistry.ts new file mode 100644 index 0000000..c10cdf9 --- /dev/null +++ b/packages/context-state2/src/test-utils/finalizationRegistry.ts @@ -0,0 +1,38 @@ +import "expose-gc" + +export function testFinalizationRegistry() { + const promises = new Map< + string, + Promise & { resolve: (tag: string) => void } + >() + + const finalizationRegistry = new FinalizationRegistry((tag: any) => { + promises.get(tag)!.resolve(tag) + }) + function tag(tag: string, v: T) { + if (promises.has(tag)) { + throw new Error("TestFinalizationRegistry: tags must be unique") + } + let resolve = (_tag: string) => {} + const promise = new Promise((res) => { + resolve = res + }) + Object.assign(promise, { resolve }) + promises.set(tag, promise as any) + finalizationRegistry.register(v, tag) + return v + } + + return { + tag, + assertFinalized(tag: string) { + global.gc!() + const promise = promises.get(tag) + expect(promise).not.toBe(undefined) + // I was doing expect(promise).resolves.toEqual(tag), but that's not testing what it should + // `promise` will resolve when the object gets garbage-collected, if it doesn't, the test times out. + // This is equivalent to just awaiting the promise: + return promise + }, + } +} diff --git a/packages/context-state2/src/types.ts b/packages/context-state2/src/types.ts new file mode 100644 index 0000000..6533c01 --- /dev/null +++ b/packages/context-state2/src/types.ts @@ -0,0 +1,31 @@ +import { StatePromise } from "./internal" +import type { Observable } from "rxjs" + +export declare type StringRecord = Record + +export interface StateNode> { + getValue: {} extends K + ? (key?: K) => T | StatePromise + : (key: K) => T | StatePromise + getState$: {} extends K + ? (key?: K) => Observable + : (key: K) => Observable +} + +interface GetObservableFn { + >( + other: K extends CK ? StateNode : never, + ): Observable + >( + other: StateNode, + keys: Omit, + ): Observable +} + +export type GetValueFn = (node: StateNode) => CT + +export type CtxFn> = ( + ctxValue: GetValueFn, + ctxObservable: GetObservableFn, + key: K, +) => Observable diff --git a/packages/context-state2/tsconfig-build.json b/packages/context-state2/tsconfig-build.json new file mode 100644 index 0000000..d193cb6 --- /dev/null +++ b/packages/context-state2/tsconfig-build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["**/*.test.*", "**/test-helpers/*.*"] +} diff --git a/packages/context-state2/tsconfig.json b/packages/context-state2/tsconfig.json new file mode 100644 index 0000000..3401b0e --- /dev/null +++ b/packages/context-state2/tsconfig.json @@ -0,0 +1,30 @@ +{ + "include": ["src"], + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "lib": ["dom", "esnext"], + "importHelpers": true, + "declaration": true, + "sourceMap": true, + "rootDir": "./src", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "moduleResolution": "node", + "baseUrl": "./src", + "paths": { + "*": ["src/*", "node_modules/*"] + }, + "jsx": "react", + "esModuleInterop": true + } +}