mirror of
https://github.com/re-rxjs/react-rxjs.git
synced 2025-12-08 18:01:51 +00:00
alternative implementation of internals
This commit is contained in:
parent
c0a790fd4f
commit
c962741c82
27
package-lock.json
generated
27
package-lock.json
generated
@ -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": {
|
||||
|
||||
7
packages/context-state2/README.md
Normal file
7
packages/context-state2/README.md
Normal file
@ -0,0 +1,7 @@
|
||||
# @react-rxjs/core
|
||||
|
||||
Please visit the website: https://react-rxjs.org
|
||||
|
||||
## Installation
|
||||
|
||||
npm install @react-rxjs/core
|
||||
21
packages/context-state2/babel.config.js
Normal file
21
packages/context-state2/babel.config.js
Normal 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")
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
],
|
||||
}
|
||||
16
packages/context-state2/jest.config.js
Normal file
16
packages/context-state2/jest.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
58
packages/context-state2/package.json
Normal file
58
packages/context-state2/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
packages/context-state2/setupTests.ts
Normal file
5
packages/context-state2/setupTests.ts
Normal 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"
|
||||
52
packages/context-state2/src/create-root.ts
Normal file
52
packages/context-state2/src/create-root.ts
Normal 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
|
||||
}
|
||||
5
packages/context-state2/src/index.tsx
Normal file
5
packages/context-state2/src/index.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./create-root"
|
||||
export * from "./route-state"
|
||||
export * from "./substate"
|
||||
export * from "./types"
|
||||
export { StatePromise } from "./internal/promises"
|
||||
2
packages/context-state2/src/internal/empty-value.ts
Normal file
2
packages/context-state2/src/internal/empty-value.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const EMPTY_VALUE = Symbol("empty")
|
||||
export type EMPTY_VALUE = typeof EMPTY_VALUE
|
||||
16
packages/context-state2/src/internal/errors.ts
Normal file
16
packages/context-state2/src/internal/errors.ts
Normal 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()
|
||||
8
packages/context-state2/src/internal/index.ts
Normal file
8
packages/context-state2/src/internal/index.ts
Normal 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"
|
||||
28
packages/context-state2/src/internal/internals.ts
Normal file
28
packages/context-state2/src/internal/internals.ts
Normal 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
|
||||
}
|
||||
76
packages/context-state2/src/internal/nested-map.ts
Normal file
76
packages/context-state2/src/internal/nested-map.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
packages/context-state2/src/internal/promises.ts
Normal file
18
packages/context-state2/src/internal/promises.ts
Normal 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! }
|
||||
}
|
||||
18
packages/context-state2/src/internal/record-utils.ts
Normal file
18
packages/context-state2/src/internal/record-utils.ts
Normal 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)]),
|
||||
)
|
||||
124
packages/context-state2/src/internal/state-instance.ts
Normal file
124
packages/context-state2/src/internal/state-instance.ts
Normal 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
|
||||
}
|
||||
181
packages/context-state2/src/internal/state-node.ts
Normal file
181
packages/context-state2/src/internal/state-node.ts
Normal 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
|
||||
}
|
||||
190
packages/context-state2/src/route-state.test.ts
Normal file
190
packages/context-state2/src/route-state.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
132
packages/context-state2/src/route-state.ts
Normal file
132
packages/context-state2/src/route-state.ts
Normal 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
|
||||
}
|
||||
561
packages/context-state2/src/substate.test.ts
Normal file
561
packages/context-state2/src/substate.test.ts
Normal 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")
|
||||
// })
|
||||
})
|
||||
})
|
||||
79
packages/context-state2/src/substate.ts
Normal file
79
packages/context-state2/src/substate.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
31
packages/context-state2/src/types.ts
Normal file
31
packages/context-state2/src/types.ts
Normal 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>
|
||||
4
packages/context-state2/tsconfig-build.json
Normal file
4
packages/context-state2/tsconfig-build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["**/*.test.*", "**/test-helpers/*.*"]
|
||||
}
|
||||
30
packages/context-state2/tsconfig.json
Normal file
30
packages/context-state2/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user