alternative implementation of internals

This commit is contained in:
Víctor Oliva 2023-06-25 20:07:37 +02:00
parent c0a790fd4f
commit c962741c82
25 changed files with 1726 additions and 1 deletions

27
package-lock.json generated
View File

@ -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": {

View File

@ -0,0 +1,7 @@
# @react-rxjs/core
Please visit the website: https://react-rxjs.org
## Installation
npm install @react-rxjs/core

View File

@ -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")
},
},
}
},
],
}

View File

@ -0,0 +1,16 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "jsdom",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
setupFilesAfterEnv: ["<rootDir>/setupTests.ts"],
globals: {
"ts-jest": {
babelConfig: true,
diagnostics: {
warnOnly: true,
},
},
},
}

View File

@ -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"
}
}

View File

@ -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"

View File

@ -0,0 +1,52 @@
import { of } from "rxjs"
import { createStateNode } from "./internal"
import { StateNode } from "./types"
type RootNodeKey<K extends string, V> = K extends "" ? {} : Record<K, V>
export interface RootNode<V, K extends string>
extends StateNode<never, RootNodeKey<K, V>> {
run: K extends "" ? () => () => void : (key: V) => () => void
}
export function createRoot(): RootNode<never, "">
export function createRoot<KeyValue, KeyName extends string>(
keyName: KeyName,
): RootNode<KeyValue, KeyName>
export function createRoot<KeyValue = never, KeyName extends string = "">(
keyName?: KeyName,
): RootNode<KeyValue, KeyName> {
const internalNode = createStateNode<
null,
RootNodeKey<KeyName, KeyValue>,
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<KeyValue, KeyName> = Object.assign(
internalNode.public as any,
{
run: (root?: KeyValue) => {
const key = (
keyName
? {
[keyName]: root,
}
: {}
) as RootNodeKey<KeyName, KeyValue>
internalNode.addInstance(key)
internalNode.activateInstance(key)
return () => {
internalNode.removeInstance(key)
}
},
},
)
return result
}

View File

@ -0,0 +1,5 @@
export * from "./create-root"
export * from "./route-state"
export * from "./substate"
export * from "./types"
export { StatePromise } from "./internal/promises"

View File

@ -0,0 +1,2 @@
export const EMPTY_VALUE = Symbol("empty")
export type EMPTY_VALUE = typeof EMPTY_VALUE

View File

@ -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()

View File

@ -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"

View File

@ -0,0 +1,28 @@
import { defer } from "rxjs"
import { StateNode } from "../types"
import { InternalStateNode, KeysBaseType } from "./state-node"
const internals = new WeakMap<
StateNode<any, any>,
InternalStateNode<any, any>
>()
export const getInternals = <T, K extends KeysBaseType>(
node: StateNode<T, K>,
): InternalStateNode<T, K> => internals.get(node)!
export function linkPublicInterface<T, K extends KeysBaseType>(
internal: InternalStateNode<T, K>,
): StateNode<T, K> {
if (internal.public) {
return internal.public
}
const node: StateNode<T, K> = {
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
}

View File

@ -0,0 +1,76 @@
export class NestedMap<K, V extends Object> {
private root: Map<K, any>
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<K, any> = this.root
let i
for (i = 0; i < keys.length - 1; i++) {
let nextCurrent = current.get(keys[i])
if (!nextCurrent) {
nextCurrent = new Map<K, any>()
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<K, any>[] = [this.root]
let current: Map<K, any> = 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<V, void, void> {
if (this.rootValue) {
yield this.rootValue
}
const mapsToIterate: Array<Map<K, any>> = [this.root]
let map: Map<K, any> | undefined
while ((map = mapsToIterate.pop())) {
for (let [_, value] of map) {
if (value instanceof Map) {
mapsToIterate.push(value)
} else {
yield value
}
}
}
}
}

View File

@ -0,0 +1,18 @@
export class StatePromise<T> extends Promise<T> {}
export interface DeferredPromise<T> {
promise: StatePromise<T>
res: (value: T) => void
rej: (err: any) => void
}
export const createDeferredPromise = <T>(): DeferredPromise<T> => {
let res: (value: T) => void
let rej: (err: any) => void
const promise = new StatePromise<T>((resolve, reject) => {
res = resolve
rej = reject
})
return { promise, res: res!, rej: rej! }
}

View File

@ -0,0 +1,18 @@
type RecordKeys = <K extends string | number>(o: Record<K, any>) => K[]
export const recordKeys: RecordKeys = Object.keys
type RecordEntries = <K extends string | number, T>(o: Record<K, T>) => [K, T][]
export const recordEntries: RecordEntries = Object.entries
type RecordFromEntries = <K extends string | number, T>(
input: [K, T][],
) => Record<K, T>
export const recordFromEntries: RecordFromEntries = Object.fromEntries
export const mapRecord = <K extends string | number, T, TT>(
data: Record<K, T>,
mapper: (x: T, i: K, o: Record<K, T>) => TT,
): Record<K, TT> =>
recordFromEntries(
recordEntries(data).map(([k, v]) => [k, mapper(v, k, data)]),
)

View File

@ -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<T, K> {
key: K
activate: () => void
kill: () => void
reset: () => void
getValue: () => T | StatePromise<T>
getState$: () => Observable<T>
}
export function createInstance<T, K extends KeysBaseType>(
key: K,
observable: Observable<T>,
): Instance<T, K> {
let subject = new BehaviorSubject<T | EMPTY_VALUE>(EMPTY_VALUE)
// TODO firehose
let deferred = createDeferredPromise<T>()
// 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<T, K> = {
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<T | EMPTY_VALUE>(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<T | EMPTY_VALUE>(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<T>
},
}
return instance
}

View File

@ -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<string, unknown>
export interface InternalStateNode<T, K extends KeysBaseType> {
keysOrder: Array<keyof K>
getInstances: () => Iterable<Instance<T, K>>
getInstance: (key: K) => Instance<T, K>
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: <TC>(node: InternalStateNode<TC, K>, key: K) => TC
public: StateNode<T, K>
}
interface GetObservableFn<K> {
<T, CK extends KeysBaseType>(
other: K extends CK ? InternalStateNode<T, CK> : never,
): Observable<T>
<T, CK extends KeysBaseType>(
other: InternalStateNode<T, CK>,
keys: Omit<CK, keyof K>,
): Observable<T>
}
export function createStateNode<T, K extends KeysBaseType, R>(
keysOrder: Array<keyof K>,
parent: InternalStateNode<T, K> | null,
instanceCreator: (
getContext: <R>(node: InternalStateNode<R, K>) => R,
getObservable: GetObservableFn<K>,
key: K,
) => Observable<R>,
): InternalStateNode<R, K> {
const instances = new NestedMap<K[keyof K], Instance<R, K>>()
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 = <TC>(otherNode: InternalStateNode<TC, K>, 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<R, K> = {
keysOrder,
getInstances: () => instances.values(),
getInstance,
addInstance,
activateInstance,
removeInstance,
resetInstance,
instanceChange$,
getContext,
public: null as any,
}
linkPublicInterface(node)
return node
}

View File

@ -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<number>()
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")
})
})
})

View File

@ -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<any>,
O extends StringRecord<((value: T) => any) | null>,
OT extends {
[KOT in keyof O]: null extends O[KOT]
? StateNode<T, K>
: O[KOT] extends (value: T) => infer V
? StateNode<V, K>
: unknown
},
>(
parent: StateNode<T, K>,
routes: O,
selector: (value: T, ctx: GetValueFn) => string & keyof O,
): [StateNode<keyof O, K>, 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<keyof K, Subscription>()
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 = <T, K extends StringRecord<any>>(
parent: StateNode<T, K>,
keys: Set<string>,
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
}

View File

@ -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<number>()
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<number>()
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<number>()
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<number>()
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<number>()
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<number>()
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<number>()
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<number>()
const contextNode = substate(root, () => contextSource$)
const source$ = new Subject<number>()
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<number>()
const contextNode = substate(root, () => contextSource$)
const source$ = new Subject<number>()
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<number>()
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<number>()
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<number>()
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<number>()
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<number>()
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<number>()
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<number>()
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<number>()
const contextNode = substate(root, () => contextSource$)
const source$ = new Subject<number>()
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<number>()
const contextNode = substate(root, () => contextSource$)
const source$ = new Subject<number>()
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<number>()
const contextNode = substate(root, () => contextSource$)
const source$ = new Subject<number>()
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<number>()
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<number>()
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<string, {}>(root)
// const teardown = jest.fn()
// const nodeA = substate(
// root,
// (_, getState$) =>
// new Observable<string>((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<string, "gameId">("gameId")
// const signal = createSignal(root)
// const nodeA = substate(
// root,
// (_, getState$, { gameId }): Observable<string> =>
// fr.tag(
// "nodeA-" + gameId,
// getState$(signal).pipe(
// withLatestFrom(getState$(nodeB).pipe(startWith(""))),
// map(([, prev]) => prev + "/a/"),
// ),
// ),
// )
// const nodeB = substate(
// root,
// (_, getState$, { gameId }): Observable<string> =>
// 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")
// })
})
})

View File

@ -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 = <T, P extends StringRecord<any>>(
parent: StateNode<any, P>,
getState$: CtxFn<T, P>,
equalityFn: (a: T, b: T) => boolean = Object.is,
): StateNode<T, P> => {
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<keyof P, Subscription>()
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
}

View File

@ -0,0 +1,38 @@
import "expose-gc"
export function testFinalizationRegistry() {
const promises = new Map<
string,
Promise<string> & { resolve: (tag: string) => void }
>()
const finalizationRegistry = new FinalizationRegistry((tag: any) => {
promises.get(tag)!.resolve(tag)
})
function tag<T extends object>(tag: string, v: T) {
if (promises.has(tag)) {
throw new Error("TestFinalizationRegistry: tags must be unique")
}
let resolve = (_tag: string) => {}
const promise = new Promise<string>((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
},
}
}

View File

@ -0,0 +1,31 @@
import { StatePromise } from "./internal"
import type { Observable } from "rxjs"
export declare type StringRecord<T> = Record<string, T>
export interface StateNode<T, K extends StringRecord<any>> {
getValue: {} extends K
? (key?: K) => T | StatePromise<T>
: (key: K) => T | StatePromise<T>
getState$: {} extends K
? (key?: K) => Observable<T>
: (key: K) => Observable<T>
}
interface GetObservableFn<K> {
<T, CK extends StringRecord<any>>(
other: K extends CK ? StateNode<T, CK> : never,
): Observable<T>
<T, CK extends StringRecord<any>>(
other: StateNode<T, CK>,
keys: Omit<CK, keyof K>,
): Observable<T>
}
export type GetValueFn = <CT>(node: StateNode<CT, any>) => CT
export type CtxFn<T, K extends StringRecord<any>> = (
ctxValue: GetValueFn,
ctxObservable: GetObservableFn<K>,
key: K,
) => Observable<T>

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["**/*.test.*", "**/test-helpers/*.*"]
}

View File

@ -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
}
}