diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ce4f8f..526a4a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Bun uses: oven-sh/setup-bun@v2 with: diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e49ed0c..1906129 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,8 +4,8 @@ "version": "2.0.0", "tasks": [ { - "type": "typescript", - "tsconfig": "tsconfig.json", + "type": "bun", + "script": "prepare", "label": "Compile TypeScript", "problemMatcher": [ "$tsc" @@ -29,6 +29,20 @@ "kind": "test", "isDefault": true } + }, + { + "type": "bun", + "script": "run test:modern", + "problemMatcher": [], + "label": "Run modern tests", + "group": "test" + }, + { + "type": "shell", + "command": "bun run test:legacy", + "problemMatcher": [], + "label": "Run legacy tests", + "group": "test" } ] -} \ No newline at end of file +} diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..4959bcb --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,96 @@ +# Event Reduce + +Event Reduce models state as values computed from observable event streams and observable state. + +## Language + +**Observable**: +A value-producing node that can be observed and composed. +_Avoid_: Source when naming the node itself. + +**Source**: +The relationship role of an **Observable** used to compute another **Observable**. +_Avoid_: Observable source as a type name. + +**ObservableValue**: +An **Observable** that stores a mutable value. +_Avoid_: Atom. + +**State**: +A non-observable value that is included in a model's state. +_Avoid_: ObservableValue. + +## Relationships + +- An **Observable** may have zero or more **Sources**. +- A **Source** is always an **Observable**. +- An **ObservableValue** is an **Observable**. +- **State** is not an **ObservableValue**. +- APIs that describe dependency relationships should use "source" language. +- Observer callbacks should use "source" language when the changed **Observable** is one of the observer's dependencies. +- Effects should use "source" language for observables they read and for the observable that invalidates them. +- Modules that define observable node types should use "observable" language, not "source" language. +- `Source` and `ISource` should not remain as aliases after the rename; they preserve branch-local drift, not compatibility. +- Specialized observable classes should use "Observable" in the class name. +- Type aliases and interfaces should use "Observable" when they name an observable node concept. +- Async event interfaces should use "Observable" when naming the event stream itself or its phase streams. +- Event functions keep event-function naming even though they are observable. +- Async events keep async-event naming; do not rename `IAsyncEvent`. +- Variables should use "source" only when the value is acting as a dependency of another **Observable**. +- Transition internals such as `iterationSource` may keep existing names when they are not part of the public naming cleanup. +- Error class names should use "Observable" when they name the observable node concept. +- Error properties should use `source` only when the property identifies the source or trigger of the error. +- Helpers that return the observable node behind a value should use "Observable" language. +- Code comments and tests should use "observable" for user-facing streams and "source" only for dependency roles; the README is out of scope for this branch cleanup. +- Public helper parameters should use "observable" language when callers pass observable nodes directly. +- Dependency graph access uses `getSources()` returning an iterable; restoring master's `sources` property is not required. +- Observable implementations should expose dependency graph access through `getSources()`, not public mutable `sources` sets. +- Observer/effect implementations should not expose public mutable source sets. +- Subscription uses branch-style observer objects; restoring master's callback subscription is not required. +- `IObserver` remains the counterpart to `IObservable`; source-specific behavior belongs in method names. +- `IObserver` should remain minimal and should not require source introspection. +- ObservableValue uses branch-style mutation mechanics; restoring master's old helpers is not required. + +## Example dialogue + +> **Dev:** "Should this type be called Source?" +> **Domain expert:** "No. It is an Observable. It is only a source when another Observable depends on it." + +## Flagged ambiguities + +- "source" was used on this branch both as the observable node type and as a dependency role; resolved: restore the master-compatible public type name **Observable**, and reserve **Source** for the dependency role. +- `getSources` and `sourceUpdated` are relationship names, not type names. +- `Effect.sources` and `Effect.invalidatingSource` are dependency-role names. +- `Effect` should keep source terminology internally but not expose a public mutable `sources` set. +- `Reduction` should keep source terminology internally but expose dependencies through `getSources()`. +- The module defining observable node concepts should keep the master-compatible `observable` name. +- The bound transform implementation is **BoundObservable**, not BoundSource. +- Names like `ObservableValue`, `MergedObservables`, and `IAsyncObservable` name concepts; names like `sources` and `getSources` name dependency relationships. +- `ObservedValue` names the value type produced by an **Observable**; **ObservableValue** names the mutable value class. +- Generic type parameters may use concept names directly, e.g. `ObservedValue`. +- `allSources(observable)` returns the root **Observable** plus all transitive **Sources**. +- `IAsyncObservable` names an async event observable with phase observables. +- `IEventFunction` extends `IObservable`; it does not need "Observable" in its own name. +- `IAsyncEvent` remains the async event concept. +- Use `observable` for a node being inspected generally; use `source` for a node reached through `getSources` or passed to `sourceUpdated`. +- Rename error classes away from Source when they name concepts, but keep `source` properties when they identify causal sources. +- `NoUnderlyingObservableError` pairs with `getUnderlyingObservable`; avoid `NotAnObservableValueError` because **ObservableValue** is a specific class. +- `InvalidStateError` keeps state language; its invalid state holder property is `observable`, not `source`. +- `StateChangeOutsideTransitionError.source` is valid because the observable is both the state container and the source of the error; only its implementation wording should say "custom observable implementation". +- `getUnderlyingObservable` returns an **Observable** node, not a source relationship. +- Test descriptions can say "observable"; local variables can say `source` when the observable is used as a dependency. +- Event factory docs should say "observable for each phase", not "source for each phase". +- `merge` takes an array parameter named `observables`; it may keep a display-name overload, but should not expose a `sources` parameter. +- The merge return type is **MergedObservables**, not MergedSources. +- Compatibility goal is to restore observable naming, not every master API shape. +- Public export order is not a compatibility requirement. +- Generated or built `lib` output is out of scope for this naming cleanup. +- Public export surface should not change beyond the agreed renames. +- `Observable` naming does not imply master's old `subscribe(callback)` API. +- `IObserver.sourceUpdated` carries the dependency-graph semantics without renaming the observer type. +- Classes that track sources may expose their own introspection, but that is not part of `IObserver`. +- The mutable value primitive should use master's **ObservableValue** name, not Atom. +- The mutable value primitive module should use master's `observableValue` name. +- Tests should use public concept names; ObservableValue tests should not keep Atom naming. +- `ObservableValue.setValue` is acceptable even though the class name is restored for compatibility. +- Model **State** names non-observable values included in model state; do not rename state concepts to ObservableValue. diff --git a/LICENSE b/LICENSE index 0ca02e3..085aff4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Simon Oxtoby +Copyright (c) 2026 Simon Oxtoby Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/bun.lock b/bun.lock index 4bc0a61..83e6552 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "event-reduce-root", "devDependencies": { - "typescript": "~5.9.3", + "typescript": "~6.0.3", }, }, "example": { @@ -14,57 +14,56 @@ "dependencies": { "event-reduce": "workspace:*", "event-reduce-react": "workspace:*", - "react": ">=16.8", - "react-dom": ">=16.8", + "react": ">=19.2.7", + "react-dom": ">=19.2.7", }, "devDependencies": { - "@types/bun": "*", - "@types/react": ">=16.8", + "@types/bun": "1.3.14", + "@types/react": ">=19.2.17", "@types/react-dom": ">=16.8", - "typescript": "~5", + "typescript": "~6.0.3", }, }, "packages/event-reduce": { "name": "event-reduce", - "version": "0.7.5", + "version": "0.8.0", "devDependencies": { - "@types/node": "*", - "typescript": "~5", + "typescript": "~6.0.3", }, }, "packages/event-reduce-react": { "name": "event-reduce-react", - "version": "0.7.5", + "version": "0.8.0", "dependencies": { "event-reduce": "workspace:^", - "use-sync-external-store": "*", + "use-sync-external-store": "1.6.0", }, "devDependencies": { - "@types/react": ">=16.8", - "@types/use-sync-external-store": "*", - "typescript": "~5", + "@types/react": ">=19.2.17", + "@types/use-sync-external-store": "1.5.0", + "typescript": "~6.0.3", }, "peerDependencies": { - "react": ">=16.8", + "react": ">=19.2.7", }, }, "tests": { "name": "event-reduce-tests", "version": "1.0.0", "dependencies": { - "event-reduce": "*", - "event-reduce-react": "*", + "event-reduce": "workspace:*", + "event-reduce-example": "workspace:*", + "event-reduce-react": "workspace:*", }, "devDependencies": { - "@happy-dom/global-registrator": "^20.6.2", - "@testing-library/react": "*", - "@testing-library/react-hooks": "*", - "@types/react": ">=16.8", - "@types/react-dom": ">=16.8", - "react": ">=16.8", - "react-dom": ">=16.8", - "react-test-renderer": ">=16.8", - "typescript": "~5", + "@happy-dom/global-registrator": "^20.10.6", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-test-renderer": "^19.2.7", + "typescript": "~6.0.2", }, }, "tests-legacy": { @@ -75,7 +74,7 @@ "event-reduce-react": "*", }, "devDependencies": { - "typescript": "~5", + "typescript": "~6.0.3", }, }, }, @@ -91,29 +90,23 @@ "@babel/runtime": ["@babel/runtime@7.18.3", "", { "dependencies": { "regenerator-runtime": "^0.13.4" } }, "sha512-38Y8f7YUhce/K7RMwTp7m0uCumpv9hZkitCbBClqQIow1qSbCvGkcegKOXpEWCQLfWmevgRiWokZ1GkpfhbZug=="], - "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.6.2", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.6.2" } }, "sha512-M1EnDvO29XUXwJ/H5AqulA3CEf5mDzXgvubW4U6F+5L1BOf7bdxTcxX74RlM78wVa4JriUmp8vKiG5qH4VUQ1g=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.10.6", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.10.6" } }, "sha512-Nu/IjRkkNxmeG2ywWsyJSO4d1BrWTqVzxCPL+gXj0b97klhmjd6wLzt6Bx/laNpkZ3WLW7zNCqmtMIbIlahqug=="], "@testing-library/dom": ["@testing-library/dom@8.13.0", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^4.2.0", "aria-query": "^5.0.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.4.4", "pretty-format": "^27.0.2" } }, "sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ=="], - "@testing-library/react": ["@testing-library/react@13.3.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^8.5.0", "@types/react-dom": "^18.0.0" } }, "sha512-DB79aA426+deFgGSjnf5grczDPiL4taK3hFaa+M5q7q20Kcve9eQottOG5kZ74KEr55v0tU2CQormSSDK87zYQ=="], - - "@testing-library/react-hooks": ["@testing-library/react-hooks@8.0.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "react-error-boundary": "^3.1.0" } }, "sha512-uZqcgtcUUtw7Z9N32W13qQhVAD+Xki2hxbTR461MKax8T6Jr8nsUvZB+vcBTkzY2nFvsUet434CsgF0ncW2yFw=="], + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], "@types/aria-query": ["@types/aria-query@4.2.2", "", {}, "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig=="], - "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], - "@types/prop-types": ["@types/prop-types@15.7.5", "", {}, "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w=="], - - "@types/react": ["@types/react@18.0.12", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-duF1OTASSBQtcigUvhuiTB1Ya3OvSy+xORCiEf20H0P0lzx+/KeVsA99U5UjLXSbyo1DRJDlLKqTeM1ngosqtg=="], + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], - "@types/react-dom": ["@types/react-dom@18.0.5", "", { "dependencies": { "@types/react": "*" } }, "sha512-OWPWTUrY/NIrjsAPkAk1wW9LZeIjSvkXRhclsFO8CZcZGCOg2G0YZy4ft+rOyYxy8B7ui5iZzi9OkDebZ7/QSA=="], + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@types/scheduler": ["@types/scheduler@0.16.2", "", {}, "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew=="], - - "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.3", "", {}, "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@1.5.0", "", {}, "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw=="], "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], @@ -125,7 +118,9 @@ "aria-query": ["aria-query@5.0.0", "", {}, "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg=="], - "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -133,7 +128,7 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "csstype": ["csstype@3.1.0", "", {}, "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "dom-accessibility-api": ["dom-accessibility-api@0.5.14", "", {}, "sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg=="], @@ -151,52 +146,42 @@ "event-reduce-tests-legacy": ["event-reduce-tests-legacy@workspace:tests-legacy"], - "happy-dom": ["happy-dom@20.6.2", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-Xk/Y0cuq9ngN/my8uvK4gKoyDl6sBKkIl8A/hJ0IabZVH7E5SJLHNE7uKRPVmSrQbhJaLIHTEcvTct4GgNtsRA=="], + "happy-dom": ["happy-dom@20.10.6", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - "lz-string": ["lz-string@1.4.4", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ=="], - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], - "react": ["react@18.1.0", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-4oL8ivCz5ZEPyclFQXaNksK3adutVS8l2xzZU0cqEFrE9Sb7fC0EFK5uEk74wIreL1DERyjvsU915j1pcT2uEQ=="], + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], - "react-dom": ["react-dom@18.1.0", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.22.0" } }, "sha512-fU1Txz7Budmvamp7bshe4Zi32d0ll7ect+ccxNu9FlObT605GOEB8BfO4tmRJ39R5Zj831VCpvQ05QPBW5yb+w=="], + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], - "react-error-boundary": ["react-error-boundary@3.1.4", "", { "dependencies": { "@babel/runtime": "^7.12.5" } }, "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA=="], + "react-is": ["react-is@19.2.7", "", {}, "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A=="], - "react-is": ["react-is@18.1.0", "", {}, "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg=="], - - "react-shallow-renderer": ["react-shallow-renderer@16.15.0", "", { "dependencies": { "object-assign": "^4.1.1", "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" } }, "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA=="], - - "react-test-renderer": ["react-test-renderer@18.1.0", "", { "dependencies": { "react-is": "^18.1.0", "react-shallow-renderer": "^16.15.0", "scheduler": "^0.22.0" } }, "sha512-OfuueprJFW7h69GN+kr4Ywin7stcuqaYAt1g7airM5cUgP0BoF5G5CXsPGmXeDeEkncb2fqYNECO4y18sSqphg=="], + "react-test-renderer": ["react-test-renderer@19.2.7", "", { "dependencies": { "react-is": "^19.2.7", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-U4TyPDJ9MsC8rFimXuJum8w40aPc9kbOZYO8Pc2/4A884i8hwJsMNA/JNyuOc/f2/37wHvk7HjpVl1V4re7Dig=="], "regenerator-runtime": ["regenerator-runtime@0.13.9", "", {}, "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="], - "scheduler": ["scheduler@0.22.0", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-6QAm1BgQI88NPYymgGQLCZgvep4FyePDWFpXVK+zNSUgHwlqpJy8VEh8Et0KxTACS4VWwMousBElAZOH9nkkoQ=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "use-sync-external-store": ["use-sync-external-store@1.2.0", "", {}, "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], - "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "@babel/highlight/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], - "bun-types/@types/node": ["@types/node@17.0.40", "", {}, "sha512-UXdBxNGqTMtm7hCwh9HtncFVLrXoqA3oJW30j6XWp5BH/wu3mVeaxo7cq5benFdBw34HB3XDT2TRPI7rXZ+mDg=="], - "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], diff --git a/bunfig.legacy.toml b/bunfig.legacy.toml new file mode 100644 index 0000000..8691789 --- /dev/null +++ b/bunfig.legacy.toml @@ -0,0 +1,2 @@ +[test] +root = "tests-legacy/dist" diff --git a/bunfig.toml b/bunfig.toml index b21c098..4dbbd1c 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,2 +1,7 @@ -[test] -preload = ["./tests/dist/setup.js"] \ No newline at end of file +[install] +minimumReleaseAge = 604800 # 1 week + +[test] +root = "tests" +pathIgnorePatterns = ["**/dist/**"] +preload = ["./tests/setup.ts"] \ No newline at end of file diff --git a/example/Counter.tsx b/example/Counter.tsx index 9605a76..8e9da46 100644 --- a/example/Counter.tsx +++ b/example/Counter.tsx @@ -1,17 +1,16 @@ -import { asyncEvent, derived, events, model, reduce, reduced } from "event-reduce"; -import { reactive } from "event-reduce-react"; -import * as React from "react"; -import { CounterListEvents } from "./CounterList"; +import { reactive } from "event-reduce-react" +import { asyncEvent, derived, events, model, reduce, reduced } from "event-reduce" +import { CounterListEvents } from "./CounterList" @events export class CounterEvents { constructor(private _parent: CounterListEvents, private _scope: { id: number }) { } - get incremented() { return this._parent.incremented.scope(this._scope); } - get decremented() { return this._parent.decremented.scope(this._scope); } - get reset() { return this._parent.reset.scope(this._scope); } - get removed() { return this._parent.counterRemoved.scope(this._scope); } - valueFetched = asyncEvent(); + get incremented() { return this._parent.incremented.scope(this._scope) } + get decremented() { return this._parent.decremented.scope(this._scope) } + get reset() { return this._parent.reset.scope(this._scope) } + get removed() { return this._parent.counterRemoved.scope(this._scope) } + valueFetched = asyncEvent() } @model @@ -21,7 +20,7 @@ export class CounterModel { private _initial: { id: number, count?: number } ) { } - get id() { return this._initial.id; } + get id() { return this._initial.id } @reduced get count() { @@ -30,15 +29,15 @@ export class CounterModel { .on(e => e.decremented, (current) => current - 1) .on(e => e.reset, () => 0) .on(e => e.valueFetched.resolved, (_, { result }) => result) - .value; + .value } @derived - get countTimesTwo() { return this.count * 2; } + get countTimesTwo() { return this.count * 2 } } export const Counter = reactive(function Counter({ model }: { model: CounterModel }) { - let events = model.events; + let events = model.events return
@@ -65,6 +64,6 @@ export const Counter = reactive(function Counter({ model }: { model: CounterMode function onFetch() { - events.valueFetched(Promise.resolve(100)); + events.valueFetched(Promise.resolve(100)) } -}); \ No newline at end of file +}) diff --git a/example/CounterList.tsx b/example/CounterList.tsx index 33097d1..ce0edac 100644 --- a/example/CounterList.tsx +++ b/example/CounterList.tsx @@ -1,18 +1,17 @@ -import { event, events, model, reduce, reduced } from "event-reduce"; -import { reactive } from "event-reduce-react"; -import * as React from "react"; -import { Counter, CounterEvents, CounterModel } from "./Counter"; +import { reactive } from "event-reduce-react" +import { event, events, model, reduce, reduced } from "event-reduce" +import { Counter, CounterEvents, CounterModel } from "./Counter" @events export class CounterListEvents { - counterAdded = event(); - counterRemoved = event<{ id: number }>(); - incremented = event<{ id: number }>(); - decremented = event<{ id: number }>(); - reset = event<{ id: number }>(); + counterAdded = event() + counterRemoved = event<{ id: number }>() + incremented = event<{ id: number }>() + decremented = event<{ id: number }>() + reset = event<{ id: number }>() } -let uid = 1; +let uid = 1 @model export class CounterListModel { @@ -23,20 +22,19 @@ export class CounterListModel { return reduce([] as CounterModel[], this.events) .on(e => e.counterAdded, (cs) => cs.concat(this.createCounter({ id: uid++ }))) .on(e => e.counterRemoved, (cs, { id }) => cs.filter(c => c.id != id)) - .onRestore((_, counterStates) => counterStates.map(c => this.createCounter(c))) - .value; + .value } private createCounter(initial: { id: number, count?: number }) { - return new CounterModel(new CounterEvents(this.events, { id: initial.id }), initial); + return new CounterModel(new CounterEvents(this.events, { id: initial.id }), initial) } } -export const CounterList = reactive(function CounterList({ model }: { model: CounterListModel; }) { +export const CounterList = reactive(function CounterList({ model }: { model: CounterListModel }) { return <>
{model.counters.map(c => )} -}); +}) diff --git a/example/index.tsx b/example/index.tsx index 601600d..e2cdecd 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -1,17 +1,16 @@ -import { enableDevTools, enableLogging } from "event-reduce"; -import * as React from "react"; -import { StrictMode } from "react"; -import * as ReactDOM from "react-dom/client"; -import { CounterList, CounterListEvents, CounterListModel } from "./CounterList"; +import { enableDevTools, enableLogging } from "event-reduce" +import { StrictMode } from "react" +import * as ReactDOM from "react-dom/client" +import { CounterList, CounterListEvents, CounterListModel } from "./CounterList" -enableLogging(); +enableLogging() -let rootModel = new CounterListModel(new CounterListEvents()); +let rootModel = new CounterListModel(new CounterListEvents()) -enableDevTools(rootModel, 'Counter List'); +enableDevTools(rootModel, { name: 'Counter List' }) ReactDOM.createRoot(document.getElementById('root')!).render( -); \ No newline at end of file +) diff --git a/example/package.json b/example/package.json index e5c74a0..9546c12 100644 --- a/example/package.json +++ b/example/package.json @@ -12,14 +12,14 @@ "dependencies": { "event-reduce": "workspace:*", "event-reduce-react": "workspace:*", - "react": ">=16.8", - "react-dom": ">=16.8" + "react": ">=19.2.7", + "react-dom": ">=19.2.7" }, "devDependencies": { - "@types/bun": "*", - "@types/react": ">=16.8", + "@types/bun": "1.3.14", + "@types/react": ">=19.2.17", "@types/react-dom": ">=16.8", - "typescript": "~5" + "typescript": "~6.0.3" }, "browserslist": [ "last 2 Chrome versions" diff --git a/example/serve.ts b/example/serve.ts index 1bf1c96..0726cab 100644 --- a/example/serve.ts +++ b/example/serve.ts @@ -1,4 +1,4 @@ -import index from "./index.html"; +import index from "./index.html" let server = Bun.serve({ port: 1234, @@ -7,6 +7,6 @@ let server = Bun.serve({ hmr: true, console: true } -}); +}) -console.log(`🚀 Server running at ${server.url}`); \ No newline at end of file +console.log(`🚀 Server running at ${server.url}`) \ No newline at end of file diff --git a/example/tsconfig.json b/example/tsconfig.json index b6937dd..4d39db3 100644 --- a/example/tsconfig.json +++ b/example/tsconfig.json @@ -1,10 +1,18 @@ { - "extends": "../tsconfig.base.json", - "compilerOptions": { - "noEmit": true, - "esModuleInterop": true, - "types": [ - "bun" - ] - } -} \ No newline at end of file + "extends": "../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "esModuleInterop": true, + "types": [ + "bun" + ] + }, + "references": [ + { + "path": "../packages/event-reduce" + }, + { + "path": "../packages/event-reduce-react" + } + ] +} diff --git a/package.json b/package.json index 07755a4..132b020 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,15 @@ "scripts": { "prepare": "tsc -b", "watch": "tsc -b -w", - "test": "tsc -b tests tests-legacy && bun test tests/dist tests-legacy/dist", + "test:modern": "bun test", + "test:legacy": "tsc -b tests-legacy && bun test --config=bunfig.legacy.toml", + "test": "bun run test:modern && bun run test:legacy", "example": "bun example/serve.ts" }, "devDependencies": { - "typescript": "~5.9.3" + "typescript": "~6.0.3" }, "resolutions": { "deasync": "0.1.30" } -} \ No newline at end of file +} diff --git a/packages/event-reduce-react/package.json b/packages/event-reduce-react/package.json index 2e5e6b4..048f864 100644 --- a/packages/event-reduce-react/package.json +++ b/packages/event-reduce-react/package.json @@ -1,33 +1,47 @@ -{ - "name": "event-reduce-react", - "description": "React integration for event-reduce: state management based on reducing observable events into state", - "version": "0.7.5", - "author": "Simon Oxtoby", - "homepage": "https://github.com/soxtoby/event-reduce", - "repository": { - "type": "git", - "url": "https://github.com/soxtoby/event-reduce.git", - "directory": "packages/event-reduce-react" - }, - "license": "MIT", - "main": "lib/index", - "types": "lib/index", - "scripts": { - "prepack": "cp ../../LICENSE ." - }, - "files": [ - "lib" - ], - "dependencies": { - "event-reduce": "workspace:^", - "use-sync-external-store": "*" - }, - "peerDependencies": { - "react": ">=16.8" - }, - "devDependencies": { - "@types/react": ">=16.8", - "@types/use-sync-external-store": "*", - "typescript": "~5" - } -} \ No newline at end of file +{ + "name": "event-reduce-react", + "description": "React integration for event-reduce: state management based on reducing observable events into state", + "version": "0.8.0", + "author": "Simon Oxtoby", + "homepage": "https://github.com/soxtoby/event-reduce", + "repository": { + "type": "git", + "url": "https://github.com/soxtoby/event-reduce.git", + "directory": "packages/event-reduce-react" + }, + "license": "MIT", + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "bun": "./src/index.ts", + "default": "./lib/index.js" + }, + "./lib/*": { + "types": "./lib/*.d.ts", + "bun": "./src/*.ts", + "default": "./lib/*.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "prepack": "cp ../../LICENSE ." + }, + "files": [ + "lib", + "src" + ], + "dependencies": { + "event-reduce": "workspace:^", + "use-sync-external-store": "1.6.0" + }, + "peerDependencies": { + "react": ">=19.2.7" + }, + "devDependencies": { + "@types/react": ">=19.2.17", + "@types/use-sync-external-store": "1.5.0", + "typescript": "~6.0.3" + } +} diff --git a/packages/event-reduce-react/src/hooks.ts b/packages/event-reduce-react/src/hooks.ts index 8208915..d0d97e7 100644 --- a/packages/event-reduce-react/src/hooks.ts +++ b/packages/event-reduce-react/src/hooks.ts @@ -1,80 +1,61 @@ -import { IObservableValue, IReduction, asyncEvent, derive, event, reduce } from "event-reduce"; -import { changeOwnedValue } from "event-reduce/lib/cleanup"; -import { ObservableValue } from "event-reduce/lib/observableValue"; -import { ValueOf } from "event-reduce/lib/types"; -import { emptyArray } from "event-reduce/lib/utils"; -import { DependencyList, useMemo, useRef, useState } from "react"; +import { asyncEvent, derive, event, type IDerivation, type IReduction, reduce, signal } from "event-reduce" +import { trackManagedObservableCreation } from "event-reduce/lib/ownership" +import { Reduction } from "event-reduce/lib/reduction" +import { isModel } from "event-reduce/lib/symbols" +import { dispose, emptyArray } from "event-reduce/lib/utils" +import { DependencyList, useEffect, useMemo, useState } from "react" /** * Creates a model that persists across renders of the component. * @param unobservableDependencies - A list of dependencies that are not observable. The model will be re-created when any of these change. */ -export function useModel(createModel: () => T, unobservableDependencies?: DependencyList) { - let modelOwner = useState(() => ({}))[0]; // Effectively makes the component the owner of the model for cleanup purposes - return useMemo(() => { - let model = createModel(); - changeOwnedValue(modelOwner, undefined, model); - return model; - }, unobservableDependencies ?? emptyArray); +export function useModel(createModel: () => T, unobservableDependencies?: DependencyList): T { + let model = useMemo(createModel, unobservableDependencies ?? emptyArray) + if (!isModel(model)) + throw new InvalidModelError() + useEffect(() => () => model[dispose]?.(), unobservableDependencies ?? emptyArray) + return model } export function useEvent(name?: string) { - return useState(() => event(name))[0]; + return useState(() => event(name))[0] } export function useAsyncEvent(name?: string) { - return useState(() => asyncEvent(name))[0]; + return useState(() => asyncEvent(name))[0] } /** * Creates a derived value that persists across renders of the component. * @param unobservableDependencies - A list of dependencies that are not observable. The derived value will be updated when any of these change. - * If not specified, the derived value will be updated every render, but will still only *trigger* a re-render inside a reactive component if the derived value changes. */ -export function useDerived(getValue: () => T, name?: string): IObservableValue; -export function useDerived(getValue: () => T, unobservableDependencies?: DependencyList, name?: string): IObservableValue; -export function useDerived(getValue: () => T, nameOrUnobservableDependencies?: string | DependencyList, name?: string): IObservableValue { - let unobservableDependencies: DependencyList | undefined; - [name, unobservableDependencies] = typeof nameOrUnobservableDependencies === 'string' +export function useDerived(getValue: () => T, name?: string): IDerivation +export function useDerived(getValue: () => T, unobservableDependencies?: DependencyList, name?: string): IDerivation +export function useDerived(getValue: () => T, nameOrUnobservableDependencies?: string | DependencyList, name?: string): IDerivation { + let unobservableDependencies: DependencyList | undefined + [name, unobservableDependencies] = typeof nameOrUnobservableDependencies == 'string' ? [nameOrUnobservableDependencies, undefined] - : [name, nameOrUnobservableDependencies]; + : [name, nameOrUnobservableDependencies] - let derived = useState(() => derive(getValue, name))[0]; + let derived = useState(() => derive(getValue, name))[0] - useMemo(() => derived.update(getValue, 'render'), unobservableDependencies); + useMemo(() => derived.update(getValue, 'render'), unobservableDependencies!) - return derived; + return derived } export function useReduced(initial: T, name?: string): IReduction { - return useState(() => reduce(initial, name))[0]; + let sourceCreation = trackManagedObservableCreation() // It's fine for this reduction to be loose, because it will be disposed on unmount + let reduction = useState(() => reduce(initial, name))[0] + sourceCreation[dispose]() + useEffect(() => () => (reduction as Reduction)[dispose]?.(), []) + return reduction } -export function useObservedProps(values: T, name: string = '(anonymous observed values)') { - let observableValues = useModel(() => ({} as Record>>)); - let nameBase = (name || '') + '.'; - - // Update any values that are already being observed - for (let [key, observableValue] of Object.entries(observableValues) as [keyof T, ObservableValue>][]) - observableValue.setValue(values[key as keyof T]); - - let latestValues = useRef(values); - latestValues.current = values; - - // Create observable values as properties are accessed - return new Proxy({} as T, { - get(_, key) { - return (observableValues[key as keyof T] - ??= new ObservableValue( - () => nameBase + String(key), - (latestValues.current)[key as keyof T])) - .value; - } - }); +export function useSignal(initialValue: T, name: string = '(anonymous signal)') { + return useState(() => signal(initialValue, name))[0] } -export function useObserved(value: T, name: string = '(anonymous observed value)') { - let observableValue = useModel(() => new ObservableValue(() => name, value)); - observableValue.setValue(value); - return observableValue; -} \ No newline at end of file +class InvalidModelError extends Error { + constructor() { super("Invalid model created in useModel. A model must be an instance of a class decorated with @model.") } +} diff --git a/packages/event-reduce-react/src/index.ts b/packages/event-reduce-react/src/index.ts index 59e8dc7..40e0569 100644 --- a/packages/event-reduce-react/src/index.ts +++ b/packages/event-reduce-react/src/index.ts @@ -1,7 +1,2 @@ -import { cleanupOptions } from "event-reduce/lib/cleanup" - -export * from "./hooks"; -export * from "./rendering"; - -let baseSkip = cleanupOptions.skipCleanup; -cleanupOptions.skipCleanup = (value: object) => baseSkip(value) || '$$typeof' in value; // Skip react elements \ No newline at end of file +export * from "./hooks" +export * from "./rendering" \ No newline at end of file diff --git a/packages/event-reduce-react/src/rendering.ts b/packages/event-reduce-react/src/rendering.ts index 0e548d9..d5b57d9 100644 --- a/packages/event-reduce-react/src/rendering.ts +++ b/packages/event-reduce-react/src/rendering.ts @@ -1,116 +1,130 @@ -import { IObservableValue, Observe } from "event-reduce"; -import { Derivation } from "event-reduce/lib/derivation"; -import { LogValue } from "event-reduce/lib/logging"; -import { ObservableValue } from "event-reduce/lib/observableValue"; -import { reactionQueue } from "event-reduce/lib/reactions"; -import { constant, nameOfFunction, unsubscribeAll } from "event-reduce/lib/utils"; -import { Children, Fragment, ReactElement, ReactNode, createElement, isValidElement, useCallback, useEffect, useState } from "react"; -import { useSyncExternalStore } from "use-sync-external-store/shim"; +import { Effect, log, startLogging } from "event-reduce" +import { LogValue } from "event-reduce/lib/logging" +import { IObservable } from "event-reduce/lib/observable" +import { beginTransition, queueTransitionEffect } from "event-reduce/lib/tracking" +import { constant, dispose, emptyArray, nameOfFunction } from "event-reduce/lib/utils" +import { Children, Fragment, ReactElement, ReactNode, createElement, isValidElement, useState } from "react" +import { useSyncExternalStore } from "use-sync-external-store/shim" export function Reactive(props: { name?: string; children: () => ReactNode; }): ReactElement { - return useReactive(props.name || 'Derived', () => createElement(Fragment, { children: props.children() })); + return useReactive(props.name || 'Derived', () => createElement(Fragment, { children: props.children() })) } export function reactive(component: (...args: Args) => ReactElement | null) { - let componentName = nameOfFunction(component) || 'ReactiveComponent'; + let componentName = nameOfFunction(component) || 'ReactiveComponent' - const reactiveComponentName = `reactive(${componentName})`; + const reactiveComponentName = `reactive(${componentName})` return { [reactiveComponentName]: (...args: Args) => useReactive(componentName, () => component(...args)) - }[reactiveComponentName]; + }[reactiveComponentName] } -export function useReactive(deriveValue: () => T): T; -export function useReactive(name: string, deriveValue: () => T): T; +export function useReactive(deriveValue: () => T): T +export function useReactive(name: string, deriveValue: () => T): T export function useReactive(nameOrDeriveValue: string | (() => T), maybeDeriveValue?: () => T): T { let [name, deriveValue] = typeof nameOrDeriveValue == 'string' ? [nameOrDeriveValue, maybeDeriveValue!] - : ['ReactiveValue', nameOrDeriveValue]; + : ['ReactiveValue', nameOrDeriveValue] - let derivation = useSyncDerivation(name); - return useRenderValue(derivation, deriveValue); + let renderEffect = useState(() => new RenderEffect(name))[0] + useSyncExternalStore(renderEffect.subscribe, renderEffect.triggerSnapshot) + return renderEffect.run(deriveValue) } -function useSyncDerivation(name: string) { - // Using a bogus derive function because we'll provide a new one every render - let renderedValue = useState(() => new RenderedValue(constant(name), constant(undefined!)))[0]; - - useEffect(renderedValue.connect); - - useSyncExternalStore(renderedValue.onInvalidated, renderedValue.getInvalidatedState); - - return renderedValue; -} - -function useRenderValue(derivation: RenderedValue, deriveValue: () => T) { - useCallback(function update() { derivation.update(deriveValue, 'render'); }, [deriveValue])(); // need to use a hook to be considered a hook in devtools - return derivation.value; -} - -class RenderedValue extends Derivation { - private readonly _invalidatedState = new ObservableValue(() => `${this.displayName}.render`, { invalidatedBy: "(nothing)" }); - - onInvalidated = (observe: Observe<{ invalidatedBy: string }>) => this._invalidatedState.subscribe(observe); - - getInvalidatedState = () => this._invalidatedState.value; +class RenderEffect extends Effect { + private _observers = new Set<() => void>() + private _trigger: IObservable | null = null + private _version = 0 + private _triggerState: unknown = { triggeredBy: "(nothing)" } + private _previousValue?: T + + constructor(displayName: string) { + super(displayName, constant(undefined!), (observable) => { + if (!this._trigger) { + log('⚛️🚩 (render invalidated)', this.displayName, [observable]) + this._trigger = observable + this._triggerState = process.env.NODE_ENV !== 'production' + ? { triggeredBy: observable.path } + : { version: ++this._version } + queueTransitionEffect(this.notifySubscribers.bind(this)) + } + }) + this.attach() + } - connect = () => { - let subscriptions = this.subscribeToSources(); - return unsubscribeAll.bind(null, subscriptions); + subscribe = (observer: () => void) => { + this._observers.add(observer) + return () => this._observers.delete(observer) } - protected override onSourcesUpdated(): void { - // Do nothing - will subscribe later + triggerSnapshot = () => this._triggerState + + notifySubscribers() { + for (let subscriber of this._observers) + subscriber() } - protected override onSourceValueChanged(source: IObservableValue) { - if (this._state == 'indeterminate') { - this._state = 'uninitialized'; - reactionQueue.current.add(() => this._invalidatedState.setValue({ invalidatedBy: source.displayName ?? "(unknown)" })); + override run(deriveValue: () => T) { + let previousValue = this._previousValue + let nextValue!: T + let trigger = this._trigger + this._trigger = null + + let transition = beginTransition(this, trigger ?? { trigger: 'render' }) + let logging = startLogging('⚛️ (render)', this.displayName, emptyArray, () => ({ + Trigger: trigger?.path ?? 'render', + Previous: loggedReactValue(previousValue), + Current: loggedReactValue(nextValue), + })) + + try { + nextValue = super.run(deriveValue) + if (process.env.NODE_ENV !== 'production') + this._previousValue = nextValue + return nextValue + } finally { + logging[dispose]() + transition[dispose]() } } +} - protected override getInvalidatedMessage() { return '⚛️🚩 (render invalidated)'; } - - protected override getUpdateMessage() { return '⚛️ (render)'; } - - protected override loggedValue(value: T) { - if (process.env.NODE_ENV !== 'production' && isValidElement(value)) { - let xmlDoc = document.implementation.createDocument(null, null); - return new LogValue([ - xmlTree(value), - { ['React element']: value } - ]); - - function xmlTree(node: T): Node { - if (isValidElement(node)) { - let type = reactElementName(node.type) - .split('(').at(-1)!.split(')')[0] // Unwrap HOC names - .replace(/^[0-9-]/, '_$&') // Escape leading number or dash - .replace(/[^a-zA-Z0-9:_.-]/g, '_'); // Escape rest of element name - let el = xmlDoc.createElement(type || ':unknown:'); - for (let child of Children.toArray((node.props as any).children)) - el.appendChild(xmlTree(child)); - return el; - } - return document.createTextNode(String(node)); +function loggedReactValue(value: unknown) { + if (process.env.NODE_ENV !== 'production' && isValidElement(value)) { + let xmlDoc = document.implementation.createDocument(null, null) + return new LogValue([ + xmlTree(value), + { ['React element']: value } + ]) + + function xmlTree(node: unknown): Node { + if (isValidElement(node)) { + let type = reactElementName(node.type) + ?.split('(').at(-1)!.split(')')[0] // Unwrap HOC names + .replace(/^[0-9-]/, '_$&') // Escape leading number or dash + .replace(/[^a-zA-Z0-9:_.-]/g, '_') // Escape rest of element name + let el = xmlDoc.createElement(type || ':unknown:') + for (let child of Children.toArray((node.props as any).children)) + el.appendChild(xmlTree(child)) + return el } + return document.createTextNode(String(node)) + } - function reactElementName(elementType: any) { - switch (typeof elementType) { - case 'string': - return elementType; - case 'function': - return nameOfFunction(elementType); - case 'symbol': - return String(elementType); - case 'object': - if ('_context' in elementType) return 'Context.Provider'; - if (elementType.displayName) return elementType.displayName; - if (elementType.type?.displayName) return elementType.type.displayName; - } + function reactElementName(elementType: any) { + switch (typeof elementType) { + case 'string': + return elementType + case 'function': + return nameOfFunction(elementType) + case 'symbol': + return String(elementType) + case 'object': + if ('_context' in elementType || ('Consumer' in elementType && 'Provider' in elementType)) return 'Context.Provider' + if (elementType.displayName) return elementType.displayName + if (elementType.type?.displayName) return elementType.type.displayName } } - return value; } -} \ No newline at end of file + return value +} diff --git a/packages/event-reduce-react/tsconfig.json b/packages/event-reduce-react/tsconfig.json index de39928..9cf8129 100644 --- a/packages/event-reduce-react/tsconfig.json +++ b/packages/event-reduce-react/tsconfig.json @@ -1,13 +1,18 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "outDir": "lib", - "rootDir": "src", - "declaration": true, - "declarationMap": true - }, - "include": [ - "src" - ] -} \ No newline at end of file + "compilerOptions": { + "composite": true, + "outDir": "lib", + "rootDir": "src", + "declaration": true, + "declarationMap": true + }, + "references": [ + { + "path": "../event-reduce" + } + ], + "include": [ + "src" + ] +} diff --git a/packages/event-reduce/package.json b/packages/event-reduce/package.json index 01c0966..4be9881 100644 --- a/packages/event-reduce/package.json +++ b/packages/event-reduce/package.json @@ -1,25 +1,38 @@ -{ - "name": "event-reduce", - "description": "State management based on reducing observable events into state", - "version": "0.7.5", - "author": "Simon Oxtoby", - "homepage": "https://github.com/soxtoby/event-reduce", - "repository": { - "type": "git", - "url": "https://github.com/soxtoby/event-reduce.git", - "directory": "packages/event-reduce" - }, - "license": "MIT", - "main": "lib/index", - "types": "lib/index", - "scripts": { - "prepack": "cp ../../readme.md . && cp ../../LICENSE ." - }, - "files": [ - "lib" - ], - "devDependencies": { - "@types/node": "*", - "typescript": "~5" - } -} \ No newline at end of file +{ + "name": "event-reduce", + "description": "State management based on reducing observable events into state", + "version": "0.8.0", + "author": "Simon Oxtoby", + "homepage": "https://github.com/soxtoby/event-reduce", + "repository": { + "type": "git", + "url": "https://github.com/soxtoby/event-reduce.git", + "directory": "packages/event-reduce" + }, + "license": "MIT", + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "bun": "./src/index.ts", + "default": "./lib/index.js" + }, + "./lib/*": { + "types": "./lib/*.d.ts", + "bun": "./src/*.ts", + "default": "./lib/*.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "prepack": "cp ../../readme.md . && cp ../../LICENSE ." + }, + "files": [ + "lib", + "src" + ], + "devDependencies": { + "typescript": "~6.0.3" + } +} diff --git a/packages/event-reduce/src/cleanup.ts b/packages/event-reduce/src/cleanup.ts deleted file mode 100644 index ee20686..0000000 --- a/packages/event-reduce/src/cleanup.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { log } from "./logging"; -import { getObservableValues, getStateProperties, isModel } from "./models"; -import { ObservableValue } from "./observableValue"; -import { dispose, emptyArray, isPlainObject, nameOfFunction } from "./utils"; - -/** value -> owners */ -const ownerRegistry = new WeakMap>(); - -type Model = object; -type TrackableValue = Model | ObservableValue; - -export function changeOwnedValue(owner: TrackableValue, oldValue: unknown, newValue: unknown) { - if (oldValue !== newValue) { - let oldTrackableValues = findTrackableValues(oldValue); - let newTrackableValues = findTrackableValues(newValue); - - for (let oldTrackableValue of oldTrackableValues) - if (!newTrackableValues.has(oldTrackableValue)) - removeOwner(owner, oldTrackableValue); - - for (let newTrackableValue of newTrackableValues) - if (!oldTrackableValues.has(newTrackableValue)) - addOwner(owner, newTrackableValue); - } -} - -function addOwner(owner: TrackableValue, value: TrackableValue) { - ownerRegistry.get(value)?.add(owner) - ?? ownerRegistry.set(value, new Set([owner])); -} - -function removeOwner(owner: TrackableValue, value: TrackableValue) { - let owners = ownerRegistry.get(value); - owners?.delete(owner); - if (!owners?.size) - disposeModel(value); -} - -export function disposeModel(model: Model) { - log('🗑️ (dispose)', nameOfFunction(model.constructor), emptyArray, undefined, () => { - ownerRegistry.delete(model); - if (model instanceof ObservableValue) { - model[dispose](); - } else if (isModel(model)) { - for (let value of Object.values(getObservableValues(model))) - removeOwner(model, value); - for (let key of getStateProperties(model)) - changeOwnedValue(model, model[key as keyof unknown], undefined); - } - }); -} - -function findTrackableValues(value: unknown, trackableValues: Set = new Set(), searchedIn: Set = new Set()) { - if (!searchedIn.has(value)) { - searchedIn.add(value); - - if (value instanceof ObservableValue || isModel(value)) { - trackableValues.add(value); - } else if (Array.isArray(value)) { - for (let item of value) - findTrackableValues(item, trackableValues, searchedIn); - } else if (isPlainObject(value) && !cleanupOptions.skipCleanup(value)) { - for (let key in value) - findTrackableValues(value[key as keyof unknown], trackableValues, searchedIn); - } - } - - return trackableValues; -} - -const cleanupOptions = { - /** Override this to avoid trying to clean up objects that are known not to contain any state. */ - skipCleanup(value: object) { return false; } -}; - -export { cleanupOptions }; diff --git a/packages/event-reduce/src/derivation.ts b/packages/event-reduce/src/derivation.ts index 21d1286..6601f72 100644 --- a/packages/event-reduce/src/derivation.ts +++ b/packages/event-reduce/src/derivation.ts @@ -1,189 +1,123 @@ -import { EventFn, IEventClass, isEvent } from "./events"; -import { log, sourceTree } from "./logging"; -import { isEventsClass } from "./models"; -import { IObservable } from "./observable"; -import { IObservableValue, ObservableValue, collectAccessedValues, startTrackingScope } from "./observableValue"; -import { Unsubscribe } from "./types"; -import { constant, emptyArray, unsubscribeAll, using } from "./utils"; +import { Effect } from "./effect" +import { startLogging } from "./logging" +import { IObservable, IObserver, Observable } from "./observable" +import { trackManagedObservableCreation } from "./ownership" +import { includeDerivedState, ModelJSON, modelJSON } from "./state" +import { beginTransition, endSourceAccessed, iterationSource, startSourceAccessed } from "./tracking" +import { dispose, emptyArray, nameOfFunction, nullDisposable } from "./utils" -let currentlyRunningDerivation = null as IObservableValue | null; +export interface IDerivation extends IObservable, IObserver { + readonly value: T -const anonymousDerivationName = constant("(anonymous derivation)"); + /** + * Forces the value to be re-calculated. + * @param deriveValue Optional function to use to derive the value. If not provided, the original derivation function will be used. + * @param reason Optional reason for the update. If not invalidated by an observable change, this will be used as the reason for the update. + **/ + update(deriveValue?: () => T, reason?: string): void +} -export function derive(getDerivedValue: () => T, name?: string, valuesEqual?: (previous: T, next: T) => boolean) { - return new Derivation(name ? constant(name) : anonymousDerivationName, getDerivedValue, valuesEqual); +export function derive(derive: () => T, displayName?: string): IDerivation +export function derive(derive: () => T, valuesEqual: (a: T, b: T) => boolean, displayName?: string): IDerivation +export function derive(derive: () => T, displayNameOrValuesEqual?: string | ((a: T, b: T) => boolean), maybeDisplayName?: string): IDerivation { + let [valuesEqual, displayName] = typeof displayNameOrValuesEqual == 'function' + ? [displayNameOrValuesEqual, maybeDisplayName] + : [Object.is, displayNameOrValuesEqual] + return new Derivation((displayName ?? nameOfFunction(derive)) || Derivation.defaultName, derive, valuesEqual) } -export class Derivation extends ObservableValue implements IObservableValue { - protected _state: DerivationState = 'uninitialized'; - private _sources = [] as ObservableValue[]; - private _sourceSubscriptions = [] as Unsubscribe[]; - protected _invalidatingSource?: IObservable; - private _sourceVersion = 0; +export class Derivation extends Observable implements IDerivation { + private _derive: Effect + private _json?: Derivation | undefined> + static defaultName = "(anonymous derivation)" + suppressLogging = false constructor( - getDisplayName: () => string, - private _deriveValue: () => T, - valuesEqual?: (previous: T, next: T) => boolean + displayName: string, + derive: () => T, + private _skipUpdate = Object.is ) { - super(getDisplayName, undefined!, valuesEqual); - } - - override get sources() { return this._sources; } - - override get version() { - this.reconcile(); - return super.version; - } - - override get value() { - this.reconcile(); - return super.value; + super(displayName) + this._derive = new Effect(displayName, derive, () => { + if (this._observers.size) + this.update() + }) } - private reconcile() { - if (this._state != 'settled' && this._state != 'updating') { - if (this._state == 'uninitialized' || this.sources.some(this.isNewerVersion.bind(this))) - this.update(); - this.onSettled(); + toJSON() { + // Lazy init to avoid infinite loop in Derivation constructor + if (!this._json) { + this._json = new Derivation(`toJSON(${this.displayName})`, () => includeDerivedState().value ? modelJSON(this.value) : undefined) + this._json.suppressLogging = true } + return this._json.value } - private isNewerVersion(source: ObservableValue) { - return source.version > this._sourceVersion; - } + override subscribe(observer: IObserver): void { + if (!this._observers.size) + this._derive.attach() - override setValue(value: T) { - this.onSettled(); - super.setValue(value); - } + if (this.lastUpdated < 0) + this.update() // Subscriber may never be notified if we don't collect any sources - private onSettled() { - this._state = 'settled'; - delete this._invalidatingSource; + super.subscribe(observer) } - get invalidatedBy() { return this._invalidatingSource?.displayName; } - - /** - * Forces the value to be re-calculated. - * @param deriveValue Optional function to use to derive the value. If not provided, the original derivation function will be used. - * @param reason Optional reason for the update. If not invalidated by a source change, this will be used as the reason for the update. - **/ - update(deriveValue?: () => T, reason?: string) { - using(startTrackingScope(), () => { - let trigger = this._invalidatingSource; - let triggerRef = trigger && new WeakRef(trigger); - let previousValue = this._value; - let value!: T; - - this._state = 'updating'; // Ensures that sources unsettled or updated during this update don't trigger further updates - - log(this.getUpdateMessage(), this.displayName, emptyArray, () => ({ - Previous: this.loggedValue(previousValue), - Current: this.loggedValue(value), - Container: this.container, - Sources: sourceTree(this.sources), - TriggeredBy: trigger - ? { name: trigger.displayName, get observable() { return triggerRef!.deref() ?? "No longer in memory"; } } - : reason - }), () => { - let newSources = collectAccessedValues(() => { - let previouslyRunningDerivation = currentlyRunningDerivation; - currentlyRunningDerivation = this; - try { - value = (deriveValue ?? this._deriveValue)(); - } finally { - currentlyRunningDerivation = previouslyRunningDerivation; - } - }); - - if (process.env.NODE_ENV !== 'production') { - if (isEvent(value) || isEventsClass(value)) - throw new DerivedEventsError(this, value); - } - - this._sources.length = 0; - for (let source of newSources) - this._sources.push(source); - this._sourceVersion = Math.max(0, ...this.sources.map(s => s.version)); - this.clearSourceInfo(); - this.onSourcesUpdated(); - - this.setValue(value); - }); - }); - } + override unsubscribe(observer: IObserver): void { + super.unsubscribe(observer) - protected onSourcesUpdated() { - this.unsubscribeFromSources(); - this._sourceSubscriptions = this.subscribeToSources(); + if (!this._observers.size) + this.detach() } - protected subscribeToSources() { - let subscriptions = [] as Unsubscribe[]; + detach() { this._derive.detach() } - for (let source of this._sources) { - subscriptions.push( - source.subscribe(this.onSourceValueChanged.bind(this, source), this.displayNameGetter), - source.unsettled.subscribe(this.onSourceUnsettled.bind(this), this.displayNameGetter) - ); - } + override getSources() { return this._derive.getSources() } - return subscriptions; - } + sourceUpdated(source: IObservable, value: S) { this._derive.sourceUpdated(source, value) } - protected onSourceUnsettled() { - if (this._state == 'settled') { - this._state = 'indeterminate'; - // Not logging this because it'd be too noisy, - // and it's more of an implementation detail than a change in the model - this.notifyObserversUnsettled(); + override get value() { + // Notifying tracker before checking for updates so circular reference is detected early + startSourceAccessed(this) + + try { + if (this._derive.isStale) + this.update() + } finally { + endSourceAccessed(this) } - } - protected onSourceValueChanged(source: IObservableValue) { - this._invalidatingSource = source; - - if (this.isObserved) - this.reconcile(); - else - log(this.getInvalidatedMessage(), this.displayName, emptyArray, () => ({ - Previous: this.loggedValue(this._value), - Container: this.container, - Sources: sourceTree(this.sources) - })); + return this._value // Not using super.value to avoid duplicate tracker notification } - protected getInvalidatedMessage() { return '🔗🚩 (derivation invalidated)'; } - - protected getUpdateMessage() { return '🔗 (derivation)'; } + protected override skipUpdate(nextValue: T) { return this._skipUpdate(this._value, nextValue) } - protected loggedValue(value: T): unknown { return value; } - - override unsubscribeFromSources() { - unsubscribeAll(this._sourceSubscriptions); - this._sourceSubscriptions.length = 0; + update(deriveValue?: () => T, reason?: string) { + let previousValue = this._value + let nextValue!: T + let trigger = this._derive.invalidatingSource + + let transition = beginTransition(this, iterationSource ? undefined : trigger ?? { reason: reason ?? '(unknown)' }) // Derivations are always safe to access, and only initiate a transition if necessary + let observableCreation = trackManagedObservableCreation() + let logging = this.suppressLogging ? nullDisposable : startLogging('🔗 (derivation)', this.displayName, emptyArray, () => ({ + Path: this.path, + Trigger: trigger?.path ?? reason ?? '(unknown)', + Previous: previousValue, + Current: nextValue + })) + try { + this.setValue(nextValue = this._derive.run(deriveValue)) + observableCreation.verifyObservableManagement() + } finally { + logging[dispose]() + observableCreation[dispose]() + transition[dispose]() + } } -} - -type DerivationState = 'uninitialized' | 'indeterminate' | 'updating' | 'settled'; - -export function ensureNotInsideDerivation(sideEffect: string) { - if (currentlyRunningDerivation) - throw new SideEffectInDerivationError(currentlyRunningDerivation, sideEffect); -} -export class SideEffectInDerivationError extends Error { - constructor( - public derivation: IObservableValue, - public sideEffect: string - ) { super(`Derivation ${derivation.displayName} triggered side effect ${sideEffect}. Derivations cannot have side effects.`); } + override[dispose]() { + this._derive[dispose]() + this._json?.[dispose]() + super[dispose]() + } } - -export class DerivedEventsError extends Error { - constructor( - public derivation: IObservableValue, - public value: EventFn | object - ) { super(`Derivation ${derivation.displayName} returned event or events class ${isEvent(value) ? value.displayName : value}. Events cannot be state.`); } -} \ No newline at end of file diff --git a/packages/event-reduce/src/devtools.ts b/packages/event-reduce/src/devtools.ts index 327e0b1..3c49331 100644 --- a/packages/event-reduce/src/devtools.ts +++ b/packages/event-reduce/src/devtools.ts @@ -1,77 +1,80 @@ -import { getState, setState } from "./state"; -import { isObject } from "./utils"; +import { ModelJSON, modelJSON } from "./state" -let modelDevTools = new Map(); +let modelDevTools = new Map() -export function enableDevTools(model: object, options?: string | IDevToolsOptions) { +export function enableDevTools(model: Model, { restoreState, ...options }: IDevToolsOptions = {}) { if (process.env.NODE_ENV !== 'production') { if (!modelDevTools.has(model) && (window as any).__REDUX_DEVTOOLS_EXTENSION__) { - let dev = (window as any).__REDUX_DEVTOOLS_EXTENSION__.connect(typeof options == 'string' ? { name: options } : options); - modelDevTools.set(model, dev); - dev.init(getState(model)); + let dev = (window as any).__REDUX_DEVTOOLS_EXTENSION__.connect(options) + modelDevTools.set(model, dev) + dev.init(modelJSON(model)) let unsubscribe = dev.subscribe((event: any) => { if (event.type == 'DISPATCH') - setState(model, JSON.parse(event.state)); - }); + restoreState?.(model, JSON.parse(event.state)) + }) return () => { - modelDevTools.delete(model); - unsubscribe(); + modelDevTools.delete(model) + unsubscribe() } } } - return () => { }; + return () => { } } -export function sendEvent(name: string, arg: any) { +export function sendEvent(name: string, arg: object) { if (process.env.NODE_ENV !== 'production') { if (modelDevTools.size) { - let event = isObject(arg) - ? getState(arg, { name }) - : { value: arg }; + let event = modelJSON(arg) as any if ('type' in event) - event['.type'] = event.type; - event.type = name; - modelDevTools.forEach((dev, model) => dev.send(event, getState(model, { includeDerivedProperties: true }))); + event['.type'] = event.type + event.type = name + modelDevTools.forEach((dev, model) => dev.send(event, modelJSON(model))) } } } /** - * redux-devtools-extension options from https://github.com/zalmoxisus/redux-devtools-extension/blob/master/docs/API/Arguments.md + * redux-devtools-extension options from https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md */ -export interface IDevToolsOptions { +export interface IDevToolsOptions { /** * The instance name to be shown on the monitor page. Default value is document.title. * If not specified and there's no document title, it will consist of tabId and instanceId. */ - name?: string; + name?: string + + /** + * Callback to restore state, used when time traveling or importing state from a file. Takes the deserialized state as an argument. + * event-reduce specific + */ + restoreState?: (model: Model, state: ModelJSON) => void /** * In ms. If more than one action is dispatched in the indicated interval, all new actions will be collected and sent at once. * It is the joint between performance and speed. When set to 0, all actions will be sent instantly. * Set it to a higher value when experiencing perf issues (also maxAge to a lower value). Default is 500 ms. */ - latency?: number; + latency?: number /** * Maximum allowed actions to be stored in the history tree. The oldest actions are removed once maxAge is reached. * It's critical for performance. Default is 50. */ - maxAge?: number; + maxAge?: number /** * If set to true, will include stack trace for every dispatched action, so you can see it in trace tab jumping directly to that part of code. * You can use a function (with action object as argument) which should return new Error().stack string, getting the stack outside of reducers. * Default to false. */ - trace?: boolean | ((action: unknown) => string); + trace?: boolean | ((action: unknown) => string) /** * Maximum stack trace frames to be stored (in case trace option was provided as true). By default it's 10. * Note that, because extension's calls are excluded, the resulted frames could be 1 less. * If trace option is a function, traceLimit will have no effect, as it's supposed to be handled there. */ - traceLimit?: number; + traceLimit?: number /** * undefined - will use regular JSON.stringify to send data (it's the fast mode). @@ -84,15 +87,15 @@ export interface IDevToolsOptions { * For function key you can also specify a custom function which handles serialization. */ options: { - date?: boolean; - regex?: boolean; - undefined?: boolean; - nan?: boolean; - infinity?: boolean; - error?: boolean; - symbol?: boolean; - map?: boolean; - set?: boolean; + date?: boolean + regex?: boolean + undefined?: boolean + nan?: boolean + infinity?: boolean + error?: boolean + symbol?: boolean + map?: boolean + set?: boolean function?: boolean | ((fn: Function) => string) } @@ -100,54 +103,54 @@ export interface IDevToolsOptions { * JSON replacer function used for both actions and states stringify. * You can specify a data type by adding a __serializedType__ key. So you can deserialize it back while importing or persisting data. */ - replacer?: (key: string, value: unknown) => any; + replacer?: (key: string, value: unknown) => any /** * JSON reviver function used for parsing the imported actions and states. */ - reviver?: (key: string, value: unknown) => any; + reviver?: (key: string, value: unknown) => any /** * Automatically serialize/deserialize immutablejs via remotedev-serialize. Should be set to the default export of immutablejs. */ - immutable?: any; + immutable?: any /** * ImmutableJS Record classes used to make possible restore its instances back when importing, persisting. */ - refs?: any[]; + refs?: any[] } /** * Function which takes action object and id number as arguments, and should return action object back. */ - actionSanitizer?: (action: unknown, id: number) => any; + actionSanitizer?: (action: unknown, id: number) => any /** * Function which takes state object and index as arguments, and should return state object back. */ - stateSanitizer?: (state: unknown, index: number) => any; + stateSanitizer?: (state: unknown, index: number) => any /** * String or array of strings as regex - actions types to be hidden in the monitors. * If actionsWhitelist specified, actionsBlacklist is ignored. */ - actionsBlacklist?: string | string[]; + actionsBlacklist?: string | string[] /** * String or array of strings as regex - actions types to be shown in the monitors. * If actionsWhitelist specified, actionsBlacklist is ignored. */ - actionsWhitelist?: string | string[]; + actionsWhitelist?: string | string[] /** * Called for every action before sending, takes state and action object, and returns true in case it allows sending the current data to the monitor. * Use it as a more advanced version of actionsBlacklist/actionsWhitelist parameters. */ - predicate?: (state: unknown, action: unknown) => boolean; + predicate?: (state: unknown, action: unknown) => boolean /** * Auto pauses when the extension’s window is not opened, and so has zero impact on your app when not in use. Default is false. */ - autoPause?: boolean; + autoPause?: boolean } \ No newline at end of file diff --git a/packages/event-reduce/src/effect.ts b/packages/event-reduce/src/effect.ts new file mode 100644 index 0000000..de87102 --- /dev/null +++ b/packages/event-reduce/src/effect.ts @@ -0,0 +1,89 @@ +import type { IObservable, IObserver } from "./observable" +import { collectAccessedSources, currentIteration } from "./tracking" +import { dispose } from "./utils" + +export class Effect implements IObserver { + private readonly _sources = new Set>() + private _attached = false + private _lastRun = -1 // Stale from the start + invalidatingSource?: IObservable + + constructor( + public readonly displayName: string, + private _effect: () => T, + private _onSourceUpdatedSinceLastRun: (source: IObservable) => void + ) { } + + getSources() { return this._sources } + + attach() { + if (!this._attached) { + this._attached = true + for (let source of this._sources) + source.subscribe(this) + } + } + + detach() { + if (this._attached) { + this._attached = false + for (let source of this._sources) + source.unsubscribe(this) + } + } + + sourceUpdated(source: IObservable, value: S): void { + if (source.lastUpdated > this._lastRun) { + this.invalidatingSource = source + this._onSourceUpdatedSinceLastRun(source) + } + } + + get isStale() { + if (this._lastRun < 0 + || this.invalidatingSource?.lastUpdated! > this._lastRun) // Fast-track for the most common case + return true + + for (let source of this._sources) + if (source.lastUpdated > this._lastRun) + return true + + return false + } + + /** + * Forces the effect to run. + * @param effect Optional function to use to run the effect. If not provided, the original effect function will be used. + **/ + run(effect?: () => T) { + this._lastRun = currentIteration + delete this.invalidatingSource + + let result: T + let tracker = collectAccessedSources(this) + + try { + result = (effect ?? this._effect)() + } finally { + tracker[dispose]() + } + + for (let oldSource of this._sources.difference(tracker.accessed)) { + this._sources.delete(oldSource) + oldSource.unsubscribe(this) + } + + for (let newSource of tracker.accessed.difference(this._sources)) { + this._sources.add(newSource) + if (this._attached) + newSource.subscribe(this) + } + + return result + } + + [dispose]() { + this.detach() + this._sources.clear() + } +} diff --git a/packages/event-reduce/src/events.ts b/packages/event-reduce/src/events.ts index 2ea3c62..e18b756 100644 --- a/packages/event-reduce/src/events.ts +++ b/packages/event-reduce/src/events.ts @@ -1,226 +1,272 @@ -import { ensureNotInsideDerivation } from "./derivation"; -import { log, logEvent } from "./logging"; -import { IObservable, ObservableOperation } from "./observable"; -import { ISubject, Subject } from "./subject"; -import { Scoped } from "./types"; -import { NamedBase, constant, filteredName, matchesScope, scopedName } from "./utils"; - -export interface IEventBase { - displayName: string; - container?: any; -} - -/** Event implementation */ -export interface IEventClass extends IEventBase { - next(...args: any): any; -} - -export interface IEvent extends IObservable, IEventBase { - (eventValue: TIn): TIn; - scope(this: IEvent, scope: Scope): IEvent, ObjectOut>; -} - -export interface IAsyncObservables { - readonly started: IObservable>; - readonly resolved: IObservable>; - readonly rejected: IObservable>; -} - -export interface IFilterableAsyncObservables extends IAsyncObservables, IEventBase { - filter(predicate: (context: Context) => boolean): IFilterableAsyncObservables; -} - -export interface IAsyncEvent extends IFilterableAsyncObservables { - >(promise: Promise, context: ContextIn): Promise; - scope(this: IAsyncEvent, scope: Scope): IAsyncEvent, ObjectContext>; -} - -export interface AsyncStart { - promise: PromiseLike; - context: Context; -} - -export interface AsyncResult { - result: Result; - context: Context; -} - -export interface AsyncError { - error: any; - context: Context; -} - -export function event(name?: string): IEvent { - return makeEventFunction(new EventSubject(name ? constant(name) : anonymousEventName)); -} - -class EventBase extends ObservableOperation { - container?: any; - - scope(this: IEvent, scope: Scope) { - return makeEventFunction(new ScopedEventSubject(this, scope)); - } -} - -class EventSubject extends EventBase implements IEventClass { - private _subject: ISubject; - - constructor(getDisplayName: () => string) { - let subject = new Subject(() => this.displayName); - super(getDisplayName, [subject], observer => subject.subscribe(observer.next, observer.getDisplayName)); - this._subject = subject; - } - - next(value: T) { - fireEvent('⚡ (event)', this.displayName, value, () => ({ Container: this.container }), - () => this._subject.next(value)); - return value; - } -} - -class ScopedEventSubject extends EventBase implements IEventClass { - constructor(private _source: IEvent, private _scope: Scope) { - super(() => scopedName(_source.displayName, _scope), [_source], - observer => _source.subscribe(value => matchesScope(_scope, value) && observer.next(value), observer.getDisplayName)); - } - - next(partial: Scoped) { - // Using plain log so only the unscoped event is logged as an event - log('{⚡} (scoped event)', this.displayName, [partial], () => ({ Scope: this._scope, Container: this.container }), - () => this._source({ ...partial, ...this._scope } as ObjectIn)); - return partial; - } -} - -const anonymousAsyncEventName = constant("(anonymous async event)"); - -/** Creates an event that takes promises as inputs, along with an optional context value */ -export function asyncEvent(name?: string): IAsyncEvent { - return makeEventFunction(new AsyncEvent(name ? constant(name) : anonymousAsyncEventName)); -} - -abstract class FilterableAsyncObservables extends NamedBase { - container?: any; - - abstract readonly started: IObservable>; - abstract readonly resolved: IObservable>; - abstract readonly rejected: IObservable>; - - filter(predicate: (context: Context) => boolean): IFilterableAsyncObservables { - return new FilteredAsyncObservables(this, predicate); - } -} - -class FilteredAsyncObservables extends FilterableAsyncObservables { - constructor(private _source: IFilterableAsyncObservables, private _predicate: (context: Context) => boolean) { - super(() => filteredName(_source.displayName, _predicate)); - - this.started = this._source.started.filter(s => this._predicate(s.context), () => `${this.displayName}.started`); - this.resolved = this._source.resolved.filter(r => this._predicate(r.context), () => `${this.displayName}.resolved`); - this.rejected = this._source.rejected.filter(r => this._predicate(r.context), () => `${this.displayName}.rejected`); - } - - readonly started: IObservable>; - readonly resolved: IObservable>; - readonly rejected: IObservable>; -} - -abstract class AsyncEventBase extends FilterableAsyncObservables { - scope(this: IAsyncEvent, scope: Scope) { - return makeEventFunction(new ScopedAsyncEvent(this, scope)); - } -} - -class AsyncEvent extends AsyncEventBase implements IEventClass { - readonly started = new Subject>(() => `${this.displayName}.started`); - readonly resolved = new Subject>(() => `${this.displayName}.resolved`); - readonly rejected = new Subject>(() => `${this.displayName}.rejected`); - - next>(promise: Promise, context: Context) { - fireEvent('⚡⌚ (async event)', this.displayName + '.started', { promise, context }, () => ({ Promise: promise, Container: this.container }), - () => this.started.next({ promise, context })); - - promise.then( - result => fireEvent('⚡✔ (async result)', this.displayName + '.resolved', { result, context }, () => ({ Promise: promise, Container: this.container }), - () => this.resolved.next({ result, context })), - error => fireEvent('⚡❌ (async error)', this.displayName + '.rejected', { error, context }, () => ({ Promise: promise, Container: this.container }), - () => this.rejected.next({ error, context }))); - - return promise; - } -} - -class ScopedAsyncEvent extends AsyncEventBase implements IEventClass { - constructor(private _source: IAsyncEvent, private _scope: Scope) { - super(() => scopedName(_source.displayName, _scope)); - - this.started = this._source.started.filter(s => matchesScope(this._scope, s.context), () => `${this.displayName}.started`); - this.resolved = this._source.resolved.filter(r => matchesScope(this._scope, r.context), () => `${this.displayName}.resolved`); - this.rejected = this._source.rejected.filter(e => matchesScope(this._scope, e.context), () => `${this.displayName}.rejected`); - } - - readonly started: IObservable>; - readonly resolved: IObservable>; - readonly rejected: IObservable>; - - next>(promise: Promise, partialContext: Scoped) { - // Using plain log so only the unscoped event is logged as an event - log('{⚡⌚} (scoped async event)', this.displayName, [partialContext], () => ({ Scope: this._scope, Container: this.container }), - () => this._source(promise, { ...this._scope, ...partialContext } as ContextIn)); - return promise; - } -} - -/** Converts an event class into a callable function */ -export function makeEventFunction(event: Event) { - Object.setPrototypeOf(eventFn, event); - eventFn[eventBrand] = true; - eventFn.apply = Function.prototype.apply; - Object.defineProperty(eventFn, 'displayName', { // Delegate to prototype, since already initialised Subjects' displayNames will have captured the prototype as 'this' - get() { return event.displayName; }, - set(value: string) { event.displayName = value; } - }); - return eventFn as EventFn; - - function eventFn(...args: any) { - return event.next.apply(eventFn, args); - } -} - -export function isEvent(event: unknown): event is EventFn { - return typeof event == 'function' - && eventBrand in event; -} - -export type EventFn = Event & Event['next'] & { [eventBrand]: true }; - -/** Logs event and ensures no other events are run at the same time. */ -export function fireEvent(type: string, displayName: string, arg: any, getInfo: (() => object) | undefined, runEvent: () => void) { - logEvent(type, displayName, arg, getInfo, () => { - try { - if (currentlyFiringEvent) - throw new ChainedEventsError(currentlyFiringEvent, displayName); - - ensureNotInsideDerivation(displayName || anonymousEvent); - - currentlyFiringEvent = displayName || anonymousEvent; - runEvent(); - } finally { - currentlyFiringEvent = null; - } - }); -} - -let currentlyFiringEvent = null as string | null; -const anonymousEvent = "(anonymous event)"; -const anonymousEventName = constant(anonymousEvent); -const eventBrand = Symbol('IsEvent'); - -export class ChainedEventsError extends Error { - constructor( - public currentEvent: string, - public newEvent = anonymousEvent - ) { - super(`Events should not be fired in response to other events. Fired '${newEvent}' in response to '${currentEvent}'.`) - } -} \ No newline at end of file +import { startLogging } from "./logging" +import { type IObservable, Observable } from "./observable" +import { markEvent } from "./symbols" +import { beginTransition } from "./tracking" +import { ObservedValue, Scoped } from "./types" +import { dispose, isObject, matchesScope, nameOfCallback, scopedName } from "./utils" + +export function event(name: string = "(anonymous event)"): IEvent { + return makeEvent({ + name, + fire(emit: (value: T) => void, value: T) { + let logging = startLogging('⚡ (event)', name, [value], () => ({ Path: this.path })) + try { + emit(value) + } finally { + logging?.[dispose]() + } + return value + }, + additionalMembers: (e: IEventFunction) => ({ + scope>(scope: Scope): IEvent, T> { + return bindEvent(e, + scopedName(e.displayName, scope), + (value: Scoped) => e({ ...value, ...scope } as In), + matchesScope(scope)) as IEvent, T> + } + }) + }) as IEvent +} + +export function asyncEvent(name: string = "(anonymous async event)"): IAsyncEvent { + return makePhasedEvent({ + name, + phases: ['started', 'resolved', 'rejected'], + fire

>( + emit: (value: AsyncValue) => void, + promise: P, + context: Context + ) { + let val = { phase: 'started', promise, context } satisfies AsyncStart + let logging = startLogging('⚡⌚ (async event)', name, [val], () => ({ Path: this.path, Promise: promise })) + try { + emit(val) + } finally { + logging?.[dispose]() + } + + promise.then( + result => { + let val = { phase: 'resolved', result, context } satisfies AsyncResult + let logging = startLogging('⚡✔ (async resolved)', name, [val], () => ({ Path: this.path, Promise: promise })) + try { + emit(val) + } finally { + logging?.[dispose]() + } + }, + error => { + let val = { phase: 'rejected', error, context } satisfies AsyncError + let logging = startLogging('⚡✘ (async rejected)', name, [val], () => ({ Path: this.path, Promise: promise })) + try { + emit(val) + } finally { + logging?.[dispose]() + } + }) + + return promise + } + }) as IAsyncEvent +} + +/** + * Creates a callable event with named phase observables. + * + * Use this to build custom async-style events, such as observable or stream events. The `fire` + * callback receives an `emit` function; emit values must include a `phase` matching one of + * `phases` and a `context` matching the last argument passed to the event. The returned event + * exposes an observable for each phase, plus `filterContext` and `scope`. + */ +export function makePhasedEvent< + const Phases extends readonly string[], + Args extends any[], + Return, + Value extends { phase: Phases[number], context: ContextArg } +>( + { + name, + phases, + fire + }: { + name: string, + phases: Phases, + fire: (emit: (value: Value) => void, ...args: Args) => Return + } & ThisType> +): IPhasedEvent { + return makeEvent({ + name, + fire, + additionalMembers: (e: IEventFunction, ScopedArgs, Return>) => ({ + ...phasedObservableMembers, CurrentContext>( + e as unknown as IObservable>, + phases as readonly (NarrowContext['phase'] & string)[] + ), + scope : never>(scope: Scope): IPhasedEvent, ScopedArgs>, Return, CurrentContext & Scope> { + return bindEvent(e, + scopedName(e.displayName, scope), + (...args: ScopedArgs>) => { + let argsWithScope = args.slice() + argsWithScope[argsWithScope.length - 1] = { ...argsWithScope[argsWithScope.length - 1], ...scope } + return e(...argsWithScope as ScopedArgs) + }, + v => matchesScope(scope, v.context), + ) as unknown as IPhasedEvent, ScopedArgs>, Return, CurrentContext & Scope> + } + }) + }) as IPhasedEvent +} + +/** Creates a callable event object. */ +export function makeEvent( + { + name, + fire, + additionalMembers + }: { + name: string, + fire: (this: IEventFunction, emit: (value: T) => void, ...args: Args) => Return, + additionalMembers?: AdditionalMembersFactory, Members> + } & ThisType> +): IEventFunction & Members { + let eventSource = new Event(name) as Event & IEventFunction + return makeObservableFunction(eventSource, event, additionalMembers) + + function event(...args: Args) { return fire.call(event as IEventFunction, eventSource.emit.bind(event), ...args) } +} + +/** Wrap an event with input and output filter. Useful for scoping. */ +export function bindEvent, Args extends any[], Return>( + event: E, + displayName: string, + fire: (...args: Args) => Return, + predicate: (value: ObservedValue) => boolean +) { + return makeObservableFunction(event.filter(predicate, displayName), fire, (event as EventWithAdditionalMembers)[membersFactory]) +} + +function phasedObservableMembers(observable: IObservable, phases: readonly (Value['phase'] & string)[]) { + let setBaseDisplayName = observable.setDisplayName + let phaseMembers = Object.fromEntries(phases.map(phase => [ + phase, + observable.filter(e => e.phase == phase, `${observable.displayName}.${phase}`) + ])) + + return { + ...phaseMembers, + filterContext(predicate: (context: Context) => boolean, displayName = `${observable.displayName}.filterContext(${nameOfCallback(predicate)})`) { + let filtered = observable.filter(e => predicate(e.context), displayName) + return Object.assign(filtered, phasedObservableMembers(filtered, phases)) + }, + setDisplayName(this: IPhasedObservable, name: string) { + setBaseDisplayName.call(observable, name) + for (let phase of phases) + this[phase].setDisplayName(`${name}.${phase}`) + } + } +} + +function makeObservableFunction, Args extends any[], Return, Members extends object>( + innerEvent: Observable, + fire: (...args: Args) => Return, + additionalMembers?: AdditionalMembersFactory +): IEventFunction, Args, Return> & Members { + let eventFn = fire as Observable & IEventFunction, Args, Return> + markEvent(eventFn) + Object.setPrototypeOf(eventFn, innerEvent) + eventFn.apply = Function.prototype.apply + eventFn.call = Function.prototype.call + return additionalMembers + ? Object.assign(eventFn, additionalMembers(eventFn), { [membersFactory]: additionalMembers as AdditionalMembersFactory, any> }) + : eventFn as Observable & IEventFunction, Args, Return> & Members +} + +export interface IEvent extends IEventFunction { + scope>(scope: Scope): IEvent, Out> +} + +export interface IEventFunction extends IObservable { + (...args: Args): Return +} + +export interface IAsyncObservable extends IObservable> { + started: IObservable> + resolved: IObservable> + rejected: IObservable> + + filterContext(predicate: (context: Context) => context is FilteredContext, displayName?: string): IAsyncObservable + filterContext(predicate: (context: Context) => boolean, displayName?: string): IAsyncObservable +} + +export interface IAsyncEvent extends IEventFunction, [promise: PromiseLike, context: Context]>, IAsyncObservable { + scope>(scope: Scope): IAsyncEvent> +} + +export type AsyncValue = + | AsyncStart + | AsyncResult + | AsyncError + +export interface AsyncStart { + phase: 'started' + context: Context + promise: PromiseLike +} + +export interface AsyncResult { + phase: 'resolved' + context: Context + result: Result +} + +export interface AsyncError { + phase: 'rejected' + context: Context + error: unknown +} + +export type IPhasedObservable = + IObservable + & { [Phase in Value['phase'] & string]: IObservable> } + & { + filterContext(predicate: (context: Context) => context is FilteredContext, displayName?: string): IPhasedObservable, FilteredContext> + filterContext(predicate: (context: Context) => boolean, displayName?: string): IPhasedObservable + } + +export type IPhasedEvent> = + IEventFunction + & IPhasedObservable + & { + scope : never>( + scope: Scope + ): IPhasedEvent, ScopedArgs>, Return, Context & Scope> + } + +export type AdditionalMembersFactory, Members extends object> = + (observable: Observable & IEventFunction, any>) => Members + +class Event extends Observable { + emit(value: T) { + let transition = beginTransition(this, isObject(value) ? value : { value }) + try { + this.setValue(value) + } finally { + this._value = undefined! // Events shouldn't hold on to their values + transition[dispose]() + } + } + override isValidAsState = false + override skipUpdate(nextValue: T) { return false } + protected override updateChildren(previousValue: T, nextValue: T) { } // Value won't be held onto +} + +const membersFactory = Symbol('event members factory') + +type ContextArg = Args extends [...any[], infer Ctx] ? Ctx : never +type NarrowContext = Value & { context: Context } +type ScopedArgs = + Args extends [...infer Rest, any] + ? [...Rest, Context] + : never + +type EventWithAdditionalMembers = { [membersFactory]?: AdditionalMembersFactory, any> } diff --git a/packages/event-reduce/src/index.ts b/packages/event-reduce/src/index.ts index ae18593..87fe428 100644 --- a/packages/event-reduce/src/index.ts +++ b/packages/event-reduce/src/index.ts @@ -1,12 +1,12 @@ -export { derive } from "./derivation"; -export { enableDevTools } from "./devtools"; -export { AsyncError, AsyncResult, AsyncStart, IAsyncEvent, IAsyncObservables, IEvent, IEventBase, IFilterableAsyncObservables, asyncEvent, event, fireEvent, makeEventFunction } from "./events"; -export { enableLogging } from "./logging"; -export { derived, events, extend, model, reduced, state } from "./models"; -export { IObservable, IObserver, Observable, Observe, allSources, merge } from "./observable"; -export { IObservableValue, valueChanged } from "./observableValue"; -export { IBoundReduction, IReduction, reduce } from "./reduction"; -export { State, getState, setState } from "./state"; -export { ISubject, Subject } from "./subject"; -export { Unsubscribe } from "./types"; -export { NamedBase, filteredName, matchesScope, scopedName } from "./utils"; +export { derive, type IDerivation } from "./derivation" +export { enableDevTools } from "./devtools" +export { Effect } from "./effect" +export { asyncEvent, event, makeEvent, makePhasedEvent, type AsyncError, type AsyncResult, type AsyncStart, type AsyncValue, type IAsyncEvent, type IAsyncObservable, type IEvent } from "./events" +export { enableLogging, log, startLogging } from "./logging" +export { createModel, derived, disposeModel, events, model, reduced, state } from "./models" +export { merge, type IObservable, type IObserver } from "./observable" +export { extendReduce, reduce, type IReduction, type Reducer } from "./reduction" +export { signal, type ISignal } from "./signal" +export { getState, stringifyState, type State } from "./state" +export { valueChanged } from "./tracking" +export { matchesScope, scopedName } from "./utils" diff --git a/packages/event-reduce/src/logging.ts b/packages/event-reduce/src/logging.ts index a7aae54..9b76248 100644 --- a/packages/event-reduce/src/logging.ts +++ b/packages/event-reduce/src/logging.ts @@ -1,11 +1,17 @@ -import { sendEvent } from "./devtools"; -import { IObservable, Observable } from "./observable"; -import { RestoreSubject } from "./reduction"; -import { emptyArray } from "./utils"; +import { dispose, nullDisposable } from "./utils" -let loggingEnabled = false; +let loggingEnabled = false -let logStack = [[]] as LogItem[][]; +let logStack = [[]] as LogItem[][] + +type LogItem = LogMessage | LogGroup + +type LogMessage = any[] + +type LogGroup = { + message: LogMessage + children: LogItem[] +} /** * For logged values that have their own specific log formatting. @@ -14,93 +20,54 @@ let logStack = [[]] as LogItem[][]; export class LogValue { constructor(public args: unknown[]) { } } export function enableLogging(enable = true) { - loggingEnabled = enable; -} - -export function logEvent(type: string, displayName: string, arg: any, getInfo: (() => object) | undefined, runEvent: () => void) { - log(type, displayName, [arg || ''], getInfo, runEvent); - sendEvent(displayName, arg); + loggingEnabled = enable } -export function log(type: string, displayName: string, args: readonly any[], getInfo?: () => Info, work?: () => void) { - if (process.env.NODE_ENV !== 'production') { - if (!loggingEnabled) - return void work?.(); - - if (work) { - logStack.push([]); - - try { - work(); - } finally { - logMessage(logStack.pop()!); - } - } else { - logMessage(); - } - - if (logStack.length == 1) { - flushLogs(logStack[0]); - logStack = [[]]; - } - - function logMessage(childLogs: LogItem[] = []) { - let group = { - message: [`${displayName} %c${type}`, 'color: grey; font-weight: normal;', ...args], - children: Object.entries(getInfo?.() ?? {} as Info) - .map(([key, value]) => value instanceof LogValue - ? [`${key}:`, ...value.args] - : [`${key}:`, value]) - .concat(childLogs) - }; - logStack.at(-1)!.push(group); - } - - function flushLogs(logs: LogItem[]) { - for (let log of logs) { - if (Array.isArray(log)) { - console.log(...log); - } else if (!log.children.length) { - console.log(...log.message); - } else { - console.groupCollapsed(...log.message); - flushLogs(log.children); - console.groupEnd(); - } - } - } +export function startLogging(type: string, displayName: string, args: readonly any[], getInfo?: () => Info): Disposable { + if (process.env.NODE_ENV !== 'production' && loggingEnabled) { + logStack.push([]) + return { [dispose]() { logMessage(type, displayName, args, getInfo, logStack.pop()!) } } } else { - return void work?.(); + return nullDisposable } } -type LogItem = LogMessage | LogGroup; - -type LogMessage = any[]; - -type LogGroup = { - message: LogMessage; - children: LogItem[]; +export function log(type: string, displayName: string, args: readonly any[], getInfo?: () => Info) { + if (process.env.NODE_ENV !== 'production' && loggingEnabled) + logMessage(type, displayName, args, getInfo) } -export interface ISourceInfo { - readonly name: string; - readonly sources: readonly ISourceInfo[]; - readonly observable: IObservable | string; +function logMessage(type: string, displayName: string, args: readonly any[], getInfo?: () => Info, childLogs: LogItem[] = []) { + try { + let group = { + message: [`${displayName} %c${type}`, 'color: grey font-weight: normal', ...args], + children: Object.entries(getInfo?.() ?? {} as Info) + .map(([key, value]) => value instanceof LogValue + ? [`${key}:`, ...value.args] + : [`${key}:`, value]) + .concat(childLogs) + } + logStack.at(-1)!.push(group) + } catch (e) { + logStack.at(-1)!.push([`${displayName} ${type} (logging failed: ${e instanceof Error ? e.message : String(e)})`, ...args]) + } + + if (logStack.length == 1) { + flushLogs(logStack[0]) + logStack = [[]] + } } -export function sourceTree(sources: readonly IObservable[]): readonly ISourceInfo[] { - if (process.env.NODE_ENV !== 'production') - return sources - .filter(s => !(s instanceof RestoreSubject)) // Since every reduction has a restore source, this is just noise - .map(s => { - let source = new WeakRef(s); - return { - name: s.displayName, - sources: (s as Observable).sourceInfo, - get observable() { return source.deref() ?? "No longer in memory"; } - } - }); - else - return emptyArray; +function flushLogs(logs: LogItem[]) { + for (let log of logs) { + if (Array.isArray(log)) { + console.log(...log) + } else if (!log.children.length) { + console.log(...log.message) + } else { + console.groupCollapsed(...log.message) + flushLogs(log.children) + console.groupEnd() + } + } } \ No newline at end of file diff --git a/packages/event-reduce/src/models.ts b/packages/event-reduce/src/models.ts index 51a61bc..eaa1b37 100644 --- a/packages/event-reduce/src/models.ts +++ b/packages/event-reduce/src/models.ts @@ -1,17 +1,69 @@ -import { changeOwnedValue } from "./cleanup"; -import { Derivation, derive } from "./derivation"; -import { isEvent } from "./events"; -import { ObservableValue, ValueIsNotObservableError, getUnderlyingObservable, protectionAgainstAccessingValueWithCommonSource } from "./observableValue"; -import { Reduction, reduce } from "./reduction"; -import { StringKey } from "./types"; -import { argsAre, constant, dispose, emptyArray, getAllPropertyDescriptors, getOrAdd, isFunction, isObject, isPlainObject, isString, isUndefined, nameOfFunction } from "./utils"; - -const ModelClassBrand = Symbol('IsModel'); -const EventsClassBrand = Symbol('IsEventsClass'); -const StateProperties = Symbol('StateProperties'); -const ObservablePropertyFactories = Symbol('ObservablePropertyInitializers'); -const LegacyObservablePropertyFactories = Symbol('ObservableValueFactories'); -const ObservableValues = Symbol('ObservableValues'); +import { Derivation, derive, IDerivation } from "./derivation" +import { IObservable, isObservable, Observable } from "./observable" +import { getChildNodes, trackManagedObservableCreation, updateChildren } from "./ownership" +import { IReduction, Reduction } from "./reduction" +import { modelJSON } from "./state" +import { EventsClass, isEvent, isEventsClass, isModel, markEventsClass, modelBrand, modelProperties, statePropertyBrand } from "./symbols" +import { collectAccessedSources, getUnderlyingObservable } from "./tracking" +import { ObservedValue } from "./types" +import { argsAre, constant, dispose, emptyArray, getAllPropertyDescriptors, getOrAddWeakEntry, getPrototypeChain, isDisposable, isFunction, isObject, isString, isUndefined, nameOfFunction } from "./utils" + +export interface Model extends Disposable { + [modelProperties]: Record +} + +export type ModelProperty = IObservable | StateProperty + +/** instance -> key -> provider */ +let instancePropertyProviders = new WeakMap ModelProperty>>() +/** prototype -> key -> provider */ +let prototypePropertyProviders = new WeakMap ModelProperty>>() +/** + * instance -> getter -> property observable + * Required for getters to cache their Observable without being overridden in sub-classes +*/ +let getterCache = new WeakMap>>() +let registeredInstanceProperties = new WeakMap() + + +/** Creates a loose model object. Not required if your model is already decorated with \@{@link model}. */ +export function createModel(create: () => T): T & Model { + let sourceCreation = trackManagedObservableCreation() + let accesses = collectAccessedSources(null) // Don't want anything accessed during construction to become a source for whatever is creating this + try { + let instance = create() + if (isModel(instance)) + return instance + let model = Object.create(instance, { + [modelProperties]: { + value: Object.fromEntries( + getChildNodes(instance) + .entries() + .flatMap(([node, path]) => isObservable(node) ? [[path, node]] : [])) + }, + [dispose]: { + value() { + Object.values(this[modelProperties]) + .filter(isDisposable) + .forEach(p => p[dispose]()) + if (isDisposable(instance)) + instance[dispose]() + } + } + }) + updateChildren(model, null, instance) + sourceCreation.verifyObservableManagement() + return model + } finally { + accesses[dispose]() + sourceCreation[dispose]() + } +} + +export function disposeModel(model: object) { + if (isModel(model)) + model[dispose]() +} /** * Marks a class as a model. @@ -19,321 +71,341 @@ const ObservableValues = Symbol('ObservableValues'); * Observable values accessed during construction will not be tracked. * The model instance will take ownership of all observable and state properties. **/ -export function model any>(target: Class): Class { - (target as any)[ModelClassBrand] = true; - - let legacyFactories = getOrAddLegacyObservablePropertyFactories(target.prototype); - - const className = nameOfFunction(target); - return { +export function model object>(target: Class): Class { + const className = nameOfFunction(target) + let thisCtor: Class + return thisCtor = { [className]: class extends target { + static [modelBrand] = true constructor(...args: any[]) { - let protection = protectionAgainstAccessingValueWithCommonSource(); // Introduces new tracking scope, while still protecting against accessing values derived from the current event + // Model decorator is ignored on base classes + let outerMostModelCtor = getPrototypeChain(new.target).find(ctor => Object.hasOwn(ctor, modelBrand)) + if (thisCtor != outerMostModelCtor) { + super(...args) + return + } + + let sourceCreation = trackManagedObservableCreation() + let accesses = collectAccessedSources(null) // Don't want anything accessed during construction to become a source for whatever is creating this + let allProperties: readonly ModelProperty[] = emptyArray try { - super(...args); + super(...args) + + let model = this as any as Model + let properties = model[modelProperties] = {} as Record + + let instanceProviders = Object.entries(getInstancePropertyProviders(model)) + for (let [key, getProperty] of instanceProviders) + properties[key] = getProperty() + + let prototypeProviders = getPrototypeChain(target.prototype).flatMap(p => Object.entries(getPrototypePropertyProviders(p))) + for (let [key, getProperty] of prototypeProviders) + properties[key] ??= getProperty(model) // Instance and sub-class properties take precedence over super-class properties + + for (let [key, property] of Object.entries(properties)) { + if (isObservable(property)) { + Object.defineProperty(model, key, { + // Bind directly to the property for cleaner call stack + get: getPrototypeChain(property) + .map(proto => Object.getOwnPropertyDescriptor(proto, 'value')) + .find(value => !!value)! + .get!.bind(property), + enumerable: true, + configurable: true + }) + } + } - let observableValues = getOrAddObservableValues(this); + allProperties = registeredInstanceProperties.get(model) ?? [] // Includes overridden super-class properties + if (process.env.NODE_ENV !== 'production' && !allProperties.length) + console.warn(`Model ${className} has no @reduced, @derived, or @state properties. You can remove the @model decorator.`) - // New decorators - let propertyFactories = getOrAddObservablePropertyFactories(this); - for (let key of Object.keys(propertyFactories)) - observableValues[key] = defineObservableProperty(this, key, propertyFactories[key]); + sourceCreation.verifyObservableManagement() - // Legacy decorators - for (let key of Object.getOwnPropertyNames(legacyFactories)) // Super class could have its own value factories - observableValues[key] = defineObservableProperty(this, key, legacyFactories[key]); - } finally { - protection[dispose](); - } + // These are no longer needed + registeredInstanceProperties.delete(model) + instancePropertyProviders.delete(model) + getterCache.delete(model) - for (let value of Object.values(getObservableValues(this))) - changeOwnedValue(this, undefined, value); + if (typeof (model as any).toJSON != 'function') + Object.assign(this, { toJSON() { return properties } }) - for (let key of getStateProperties(this)) { - let value = this[key]; - if (process.env.NODE_ENV !== 'production') { - if (isEvent(value) || isEventsClass(value)) - throw new EventsMarkedAsStateError(this, key); - } - changeOwnedValue(this, undefined, this[key]); + Object.assign(this, { + [dispose]() { + for (let property of allProperties) + updateChildren(model, property, null) + super[dispose]?.() + } + }) + } finally { + accesses[dispose]() + sourceCreation[dispose]() } } } - }[className]; -} - -function defineObservableProperty(model: any, key: string, createObservableValue: (instance: any) => ObservableValue) { - let observableValue = createObservableValue(model); - observableValue.container = model; - Object.defineProperty(model, key, { - get() { return observableValue.value; }, - enumerable: true, - configurable: true - }); - return observableValue; + }[className] } -export function reduced(accessor: ClassAccessorDecoratorTarget, context: ClassAccessorDecoratorContext): ClassAccessorDecoratorResult; -export function reduced(getter: () => Value, context: ClassGetterDecoratorContext): void; -export function reduced(prototype: object, key: string | symbol): void; -export function reduced(...args: - | [ClassAccessorDecoratorTarget, ClassAccessorDecoratorContext] - | [() => Value, ClassGetterDecoratorContext] +export function reduced(accessor: ClassAccessorDecoratorTarget, context: ClassAccessorDecoratorContext): ClassAccessorDecoratorResult +export function reduced(getter: () => Value, context: ClassGetterDecoratorContext): void +export function reduced(prototype: object, key: string | symbol): void +export function reduced(...args: + | [ClassAccessorDecoratorTarget, ClassAccessorDecoratorContext] + | [() => Value, ClassGetterDecoratorContext] | [Object, string | symbol] ) { if (argsAre(args, isClassAccessorDecoratorTarget, isClassAccessorDecoratorContext)) { // Auto-accessor property decorator - let [_, context] = args; + let [target, context] = args return { - init(this: Model, initial: Value) { - assertIsModel(this, context.name); - - let reduction = getUnderlyingObservable(initial); - if (!(reduction instanceof Reduction)) - throw new InvalidReducedPropertyError(context.name); - - getOrAddObservablePropertyFactories(this)[context.name] = constant(reduction); - - return initial; + init(this: Target, initial: Value) { + return cachedGetter( + context.name, + target.get, + factory => addInstancePropertyFactory(this, context.name, factory), + instance => getUnderlyingReduction(instance, context.name, () => initial) + ).call(assertIsModel(this, context.name)) }, - get(this: Model) { - return getObservableValues(this)?.[context.name as string].value as Value; - } - }; + get(this: Target) { return (getModelGetterCache(this).get(target.get) as Reduction)!.value }, + } } else if (argsAre(args, isFunction, isDecoratorContext)) { // Getter property decorator - let [getter, context] = args; - - context.addInitializer(function (this: Model) { - assertIsModel(this, context.name); + let [getter, context] = args + return cachedGetter( + context.name, + getter, + factory => addInstancePropertyFactoryInitializer(context, factory), + instance => getUnderlyingReduction(instance, context.name, getter)) - getOrAddObservablePropertyFactories(this)[context.name] = getterReduction.bind(null, this, context.name, getter); - }); } else { // Legacy decorator - let [prototype, key] = args; - let property = Object.getOwnPropertyDescriptor(prototype, key)!; + let [prototype, key] = args + let property = Object.getOwnPropertyDescriptor(prototype, key)! if (property?.get) { - let getter = property.get; - getOrAddLegacyObservablePropertyFactories(prototype)[key] = instance => getterReduction(instance, key, getter); + let getter = property.get + return { - get(this: object) { - let observableValue = getObservableValue(this, key as string); - if (!observableValue) - throw new MissingModelDecoratorError(this, key); - return observableValue.value; - }, + get: cachedGetter( + key, + getter, + factory => addPrototypePropertyFactory(prototype, key, factory), + instance => getUnderlyingReduction(assertIsModel(instance, key), key, getter)), enumerable: true, configurable: true - }; + } } else { - return observableValueProperty(key, Reduction, () => new InvalidReducedPropertyError(key)); + return sourceSetter(key, Reduction, () => new InvalidReducedPropertyError(key)) } } -} -function getterReduction(instance: object, key: string | symbol, getter: () => Value) { - let observableValue = getUnderlyingObservable(getter.call(instance)); - if (!(observableValue instanceof Reduction)) - throw new InvalidReducedPropertyError(key); - return observableValue; + function getUnderlyingReduction(instance: any, key: PropertyKey, getter: Function) { + let reduction = getUnderlyingObservable(getter.call(instance)) + + if (!(reduction instanceof Reduction)) + throw new InvalidReducedPropertyError(key) + + if (reduction.displayName == Reduction.defaultName) + reduction.displayName = String(key) + + return reduction + } } -export function derived(getter: () => Value, context: ClassGetterDecoratorContext): void; -export function derived(valuesEqual: (previous: Value, next: Value) => boolean): { (getter: () => Value, context: ClassGetterDecoratorContext): void; (prototype: Object, key: string | symbol): void; }; -export function derived(prototype: object, key: string | symbol): void; -export function derived(...args: +export function derived(getter: () => Value, context: ClassGetterDecoratorContext): void +export function derived(valuesEqual: (previous: Value, next: Value) => boolean): { (getter: () => Value, context: ClassGetterDecoratorContext): void; (prototype: Object, key: string | symbol): void; } +export function derived(prototype: object, key: string | symbol): void +export function derived(...args: | [(previous: Value, next: Value) => boolean] - | [() => Value, ClassGetterDecoratorContext] + | [() => Value, ClassGetterDecoratorContext] | [object, string | symbol] ) { - let valuesEqual = undefined as ((previous: any, next: any) => boolean) | undefined; + let valuesEqual = undefined as ((previous: any, next: any) => boolean) | undefined if (argsAre(args, isFunction)) { - [valuesEqual] = args; - return decorate; + [valuesEqual] = args + return decorate } else if (argsAre(args, isFunction, isDecoratorContext)) { - return decorate(...args); + return decorate(...args) } else { - return decorate(...args); + return decorate(...args) } - function decorate(getter: () => Value, context: ClassGetterDecoratorContext): void; - function decorate(prototype: object, key: string | symbol): void; - function decorate(...args: - | [() => Value, ClassGetterDecoratorContext] + function decorate(getter: () => Value, context: ClassGetterDecoratorContext): void + function decorate(prototype: object, key: string | symbol): void + function decorate(...args: + | [() => Value, ClassGetterDecoratorContext] | [object, string | symbol] ) { if (argsAre(args, isFunction, isDecoratorContext)) { // Getter property decorator - let [getter, context] = args; - context.addInitializer(function (this: Model) { - assertIsModel(this, context.name); - - getOrAddObservablePropertyFactories(this)[context.name] = () => derive(getter.bind(this), String(context.name), valuesEqual); - }); + let [getter, context] = args + return cachedGetter( + context.name, + getter, + factory => addInstancePropertyFactoryInitializer(context, factory), + instance => derive(getter.bind(instance), valuesEqual ?? Object.is, String(context.name))) } else { // Legacy decorator - let [prototype, key] = args; - let property = Object.getOwnPropertyDescriptor(prototype, key)!; + let [prototype, key] = args + let property = Object.getOwnPropertyDescriptor(prototype, key)! if (property?.get) { - let getter = property.get; - let createDerivation = (instance: any) => derive(getter.bind(instance), String(key), valuesEqual); - getOrAddLegacyObservablePropertyFactories(prototype)[key] = createDerivation; + let getter = property.get return { - get() { return getOrSetObservableValue(this, key, () => createDerivation(this)).value; }, + get: cachedGetter( + key, + getter, + factory => addPrototypePropertyFactory(prototype, key, factory), + instance => derive(getter.bind(assertIsModel(instance, key)), valuesEqual ?? Object.is, String(key))), enumerable: true, configurable: true } + } else { + return sourceSetter(key, Derivation, () => new InvalidDerivedPropertyError(key)) } - - return observableValueProperty(key, Derivation, () => new InvalidDerivedPropertyError(key)); } } } -function observableValueProperty>( - key: string | symbol, - type: new (...args: any) => Type, - createInvalidPropertyError: () => Error -): PropertyDescriptor { - return { set }; - - function set(this: any, value: any) { - let observableValue = getUnderlyingObservable(value); - if (!observableValue || !(observableValue instanceof type)) - throw createInvalidPropertyError(); - observableValue.displayName = String(key); - observableValue.container = this; - getOrAddObservableValues(this)[key as string] = observableValue; +function cachedGetter | IReduction>( + key: PropertyKey, + getter: () => ObservedValue, + addPropertyFactory: (cachedSource: (instance: any) => Observable) => void, + createSource: (instance: Model) => Observable +) { + addPropertyFactory(cachedSource) + + /** Ensures observable is only created once during model construction. */ + return function cachedGetter(this: Model) { + return cachedSource(this).value + } + + function cachedSource(instance: Model) { + let cache = getModelGetterCache(instance) + let observable = cache.get(getter) as Observable + if (!observable) + cache.set(getter, observable = registerModelProperty(instance, key, createSource)) + return observable + } +} + +function addInstancePropertyFactoryInitializer(context: ClassGetterDecoratorContext | ClassAccessorDecoratorContext, cachedSource: (instance: any) => IObservable) { + context.addInitializer(function (this: Model) { + assertIsModel(this, context.name) + addInstancePropertyFactory(this, context.name, cachedSource) + }) +} + +function addInstancePropertyFactory(instance: Model, key: PropertyKey, cachedSource: (instance: any) => IObservable) { + getInstancePropertyProviders(instance)[key] = cachedSource.bind(null, instance) +} + +function addPrototypePropertyFactory(prototype: object, key: PropertyKey, cachedSource: (instance: any) => IObservable) { + getPrototypePropertyProviders(prototype)[key] = cachedSource +} + +function sourceSetter(key: PropertyKey, sourceType: { new(...args: any[]): IObservable; defaultName: string; }, errorFactory: () => Error): PropertyDescriptor { + return { set } + + function set(this: object, value: unknown) { + assertIsModel(this as Model, key) + + let observable = getUnderlyingObservable(value) + if (!(observable instanceof sourceType)) + throw errorFactory() + + if (observable.displayName == sourceType.defaultName) + observable.setDisplayName(String(key)) + + registerModelProperty(this as Model, key, () => observable) + getInstancePropertyProviders(this)[key] = constant(observable) + Object.defineProperty(this, key, { - get: () => observableValue!.value, + get: () => (observable as Observable).value, set, enumerable: true, configurable: true - }); + }) } } -export function state(value: undefined, context: ClassFieldDecoratorContext): void; -export function state(accessor: ClassAccessorDecoratorTarget, context: ClassAccessorDecoratorContext): void; +/** Mark a property as part of the model's state. */ +export function state(value: undefined, context: ClassFieldDecoratorContext): void +/** Mark a property as part of the model's state. */ +export function state(accessor: ClassAccessorDecoratorTarget, context: ClassAccessorDecoratorContext): void /** Mark a constructor parameter as a state property by specifying its name. */ -export function state(property: Key): (target: Function, ...rest: any) => void; //..rest: any to support both constructor parameters and classes +export function state(property: Key): (target: Function, ...rest: any) => void //..rest: any to support both constructor parameters and classes /** Mark a property as a state property. */ -export function state(target: object, key: string): void; -export function state(...args: +export function state(target: object, key: string): void +export function state(...args: | string[] - | [undefined, ClassFieldDecoratorContext] - | [ClassAccessorDecoratorTarget, ClassAccessorDecoratorContext] + | [undefined, ClassFieldDecoratorContext] + | [ClassAccessorDecoratorTarget, ClassAccessorDecoratorContext] | [string] | [object, string] ) { if (argsAre(args, isObject, isString)) { // Legacy decorator - let [instance, key] = args; - addStateProp(instance.constructor.prototype, key); + let [prototype, key] = args + getPrototypePropertyProviders(prototype)[key] = instance => registerModelProperty(instance, key, () => new StateProperty(instance, key)) } else if (argsAre(args, isUndefined, isClassFieldDecoratorContext)) { // Field decorator - let [_, context] = args; - context.addInitializer(function (this: Model) { addStateProp(Object.getPrototypeOf(this), String(context.name)); }); + let [_, context] = args + context.addInitializer(function (this: Target) { + assertIsModel(this, context.name) + getInstancePropertyProviders(this)[context.name] = () => registerModelProperty(this, context.name, () => new StateProperty(this, context.name)) + }) } else if (argsAre(args, isClassAccessorDecoratorTarget, isClassAccessorDecoratorContext)) { // Auto-accessor property decorator - let [_, context] = args; - context.addInitializer(function (this: Model) { addStateProp(Object.getPrototypeOf(this), String(context.name)); }); + let [_, context] = args + context.addInitializer(function (this: Target) { + assertIsModel(this, context.name) + getInstancePropertyProviders(this)[context.name] = () => registerModelProperty(this, context.name, () => new StateProperty(this, context.name)) + }) } else { // Class decorator, or legacy constructor parameter decorator return function decorate(ctor: new (...ctorArgs: any[]) => any) { for (let key of args) - addStateProp(ctor.prototype, key); + getPrototypePropertyProviders(ctor.prototype)[key] = instance => registerModelProperty(instance, key, () => new StateProperty(instance, key)) } } } -function addStateProp(prototype: any, key: string) { - getOrAdd(prototype, StateProperties, () => [] as string[]) - .push(key); +function assertIsModel(model: Target, key: PropertyKey) { + if (process.env.NODE_ENV !== 'production' && !getPrototypeChain(model.constructor).some(p => Object.hasOwn(p, modelBrand))) + throw new MissingModelDecoratorError(model, key) + return model } -function isClassFieldDecoratorContext(context: ClassFieldDecoratorContext | unknown): context is ClassFieldDecoratorContext { - return isDecoratorContext(context) && context.kind == 'field'; +function registerModelProperty(model: Model, key: PropertyKey, createProperty: (model: Model) => Property) { + let property = createProperty(model) + updateChildren(model, null, property, `.${String(key)}`) + getOrAddWeakEntry(registeredInstanceProperties, model, () => []).push(property) + return property } -function isClassAccessorDecoratorTarget(context: ClassAccessorDecoratorTarget | unknown): context is ClassAccessorDecoratorTarget { - return isObject(context) && !!(context as ClassAccessorDecoratorTarget).get; -} - -function isClassAccessorDecoratorContext(context: ClassAccessorDecoratorContext | unknown): context is ClassAccessorDecoratorContext { - return isDecoratorContext(context) && context.kind == 'accessor'; -} - -function isDecoratorContext(context: ClassMemberDecoratorContext | unknown): context is DecoratorContext { - return isObject(context) && !!(context as ClassMemberDecoratorContext).kind; -} - -function assertIsModel(model: object, propertyName: string | symbol) { - if (!(ModelClassBrand in model.constructor)) - throw new MissingModelDecoratorError(model, propertyName); -} - -export function isModel(model: T): model is T & object { - return isObject(model) - && (ModelClassBrand in Object.getPrototypeOf(model) // TODO only check this - the other checks are for legacy decorators with legacy fields - || ObservableValues in model - || !!getStateProperties(model).length); -} - -export function extend(reducedValue: T) { - let source = getUnderlyingObservable(reducedValue); - if (source && source instanceof Reduction) - return reduce(reducedValue).on(source, (_, val) => val); - throw new ValueIsNotObservableError(reducedValue); -} +export class StateProperty implements Disposable { + readonly [statePropertyBrand] = true -export function getOrSetObservableValue(instance: any, key: string | symbol, createObservableValue: () => ObservableValue) { - return getOrAdd(getOrAddObservableValues(instance), key, () => Object.assign(createObservableValue(), { container: instance })); -} - -function getOrAddObservableValues(instance: any) { - return getOrAdd(instance, ObservableValues, () => ({} as Record>)); -} - -export function getObservableValue(instance: any, key: string) { - let observableValues = getObservableValues(instance); - return Object.hasOwn(observableValues, key) - ? observableValues[key] - : undefined; -} - -export function getObservableValues(instance: any): { [property: string]: ObservableValue } { - return isObject(instance) && ObservableValues in instance - ? instance[ObservableValues] - : {}; -} + constructor( + private _instance: any, + public readonly key: PropertyKey + ) { + if (process.env.NODE_ENV !== 'production') { + if (isEvent(this.value) || isEventsClass(this.value)) + throw new EventsMarkedAsStateError(_instance, this.key) + } -/** - * Initializing observable properties before construction is complete can cause problems if they access an uninitialized field, - * so we store the initializers on the instance and run them after construction. - **/ -function getOrAddObservablePropertyFactories(instance: any) { - return getOrAdd ObservableValue>>(instance, ObservablePropertyFactories, () => ({})); -} + updateChildren(_instance, null, this.value) + } -/** Same as getOrAddObservablePropertyInitializers, but for old experimental decorators, which only have access to the prototype. */ -function getOrAddLegacyObservablePropertyFactories(prototype: any) { - return getOrAdd ObservableValue>>(prototype, LegacyObservablePropertyFactories, - base => base ? Object.create(base) : {}); // Because we're dealing with prototypes, we need to inherit from base class' factories -} + toJSON() { return modelJSON(this.value) } -export function getStateProperties(model: T): readonly string[] { - if (isPlainObject(model)) - return emptyArray; + get value() { return this._instance[this.key] } - let prototype = Object.getPrototypeOf(model); - return getStateProperties(prototype) - .concat(prototype[StateProperties] as StringKey[] | undefined || emptyArray); + [dispose]() { updateChildren(this._instance, this.value, null) } } /** @@ -342,65 +414,95 @@ export function getStateProperties(model: T): readonly string[] { * Events returned from getters will be snapshotted, so the getter function is only called once. */ export let events = (target: T): T => { - const className = nameOfFunction(target); + const className = nameOfFunction(target) return { [className]: class extends target { constructor(...args: any[]) { - super(...args); + super(...args) for (let [key, property] of getAllPropertyDescriptors(this)) { - let value = this[key]; + let value = this[key] if (isEvent(value)) { - value.displayName = key; - value.container = this; + value.setDisplayName(key) + updateChildren(this as any as EventsClass, null, value, `.${key}`) if (property.get) Object.defineProperty(this, key, { value, configurable: true, enumerable: true - }); + }) } } - (this as any)[EventsClassBrand] = true; + markEventsClass(this) } } - }[className]; + }[className] +} + +export function getModelProperties(model: Model): Record { + return model[modelProperties] +} + +function isClassFieldDecoratorContext(context: ClassFieldDecoratorContext | unknown): context is ClassFieldDecoratorContext { + return isDecoratorContext(context) && context.kind == 'field' +} + +function isClassAccessorDecoratorTarget(context: ClassAccessorDecoratorTarget | unknown): context is ClassAccessorDecoratorTarget { + return isObject(context) && !!(context as ClassAccessorDecoratorTarget).get +} + +function isClassAccessorDecoratorContext(context: ClassAccessorDecoratorContext | unknown): context is ClassAccessorDecoratorContext { + return isDecoratorContext(context) && context.kind == 'accessor' +} + +function isDecoratorContext(context: ClassMemberDecoratorContext | unknown): context is DecoratorContext { + return isObject(context) && !!(context as ClassMemberDecoratorContext).kind +} + +/** For new decorators which have access to the instance. */ +function getInstancePropertyProviders(instance: object): Record ModelProperty> { + return getOrAddWeakEntry(instancePropertyProviders, instance, () => ({})) } -export function isEventsClass(value: unknown): value is Object { - return value instanceof Object - && EventsClassBrand in value; +/** For class decorators and legacy decorators that don't have access to the instance. */ +function getPrototypePropertyProviders(prototype: object): Record ModelProperty> { + return getOrAddWeakEntry(prototypePropertyProviders, prototype, () => ({})) +} + +/** For caching getters during model construction, so accessing them doesn't create extra sources. */ +function getModelGetterCache(instance: object): Map { + return getOrAddWeakEntry(getterCache, instance, () => new Map()) } export class MissingModelDecoratorError extends Error { constructor( public model: object, - public property: string | symbol + public property: PropertyKey ) { - super(`${nameOfFunction(model.constructor)} class must be marked as a @model to use @reduced, @derived, or @state properties.`); + super(`${nameOfFunction(model.constructor)} class must be marked as a @model to use @reduced, @derived, or @state properties.`) } } -export class EventsMarkedAsStateError extends Error { +export class InvalidReducedPropertyError extends Error { constructor( - public model: object, - public property: string | symbol + public property: PropertyKey ) { - super(`Model property ${String(property)} returns an event or events class, and cannot be marked as @state.`); + super(`@reduced property '${String(property)}' must return, or be set to, the value of a reduction.`) } } -export class InvalidReducedPropertyError extends Error { +export class InvalidDerivedPropertyError extends Error { constructor( - public property: string | symbol + public property: PropertyKey ) { - super(`@reduced property '${String(property)}' must return, or be set to, the value of a reduction.`); + super(`@derived property ${String(property)} must have a getter or be set to the value of a derivation.`) } } -export class InvalidDerivedPropertyError extends Error { +export class EventsMarkedAsStateError extends Error { constructor( - public property: string | symbol + public model: object, + public property: PropertyKey ) { - super(`@derived property ${String(property)} must have a getter or be set to the value of a derivation.`); + super(`Model property ${String(property)} returns an event or events class, and cannot be marked as @state.`) } -} \ No newline at end of file +} diff --git a/packages/event-reduce/src/observable.ts b/packages/event-reduce/src/observable.ts index fd3579d..50d5854 100644 --- a/packages/event-reduce/src/observable.ts +++ b/packages/event-reduce/src/observable.ts @@ -1,151 +1,183 @@ -import { ISourceInfo, sourceTree } from "./logging"; -import { Unsubscribe } from "./types"; -import { constant, dispose, emptyArray, filteredName, NamedBase, nameOfCallback } from "./utils"; - -export type Observe = (value: T) => void; - -export interface IObservable { - subscribe(observe: Observe, getObserverName?: () => string): Unsubscribe; - [dispose](): void; - filter(condition: (value: T) => boolean, getDisplayName?: () => string): IObservable; - map(select: (value: T) => U, getDisplayName?: () => string): IObservable; - filterMap(select: (value: T) => U | undefined, getDisplayName?: () => string): IObservable; - - displayName: string; - readonly sources: readonly IObservable[]; +import type { IEventFunction } from "./events" +import { getPath, updateChildren } from "./ownership" +import { isEventsClass } from "./symbols" +import { currentIteration, endSourceAccessed, iterationSource, startSourceAccessed } from "./tracking" +import type { ObservedValue } from "./types" +import { dispose, emptyArray, identity, isObject, nameOfCallback, nameOfFunction } from "./utils" + +export interface IObservable extends Disposable { + readonly displayName: string + setDisplayName(name: string): void + readonly path: string + getSources(): Iterable> + readonly lastUpdated: number + subscribe(observer: IObserver): void + unsubscribe(observer: IObserver): void + + map(transform: (value: T) => Out, displayName?: string): IObservable + filter(predicate: (value: T) => value is Out, displayName?: string): IObservable + filter(predicate: (value: T) => boolean, displayName?: string): IObservable + filterMap(transform: (value: T) => Out | undefined, displayName?: string): IObservable } export interface IObserver { - getDisplayName(): string; - next: Observe; + readonly displayName: string + sourceUpdated(source: IObservable, value: S): void } -export function merge(observables: IObservable[]): IObservable { - let getDisplayName = () => `merged(${observables.map(o => o.displayName).join(', ')})`; - return new ObservableOperation(getDisplayName, observables, - observer => { - let unsubscribes = observables.map(o => o.subscribe(value => observer.next(value), getDisplayName)); - return () => unsubscribes.forEach(u => u()); - }); +export function isObservable(value: any): value is IObservable { + return value instanceof Observable } -const anonymousObserverName = constant("(anonymous observer)"); +export abstract class Observable implements IObservable { + protected readonly _observers = new Set>() + protected _value!: T + lastUpdated = -1 -export class Observable extends NamedBase { - protected _observers = new Set>(); - private _sourceInfo?: readonly ISourceInfo[]; + constructor(public displayName: string) { } - get sources() { return emptyArray as readonly IObservable[]; } + setDisplayName(name: string) { this.displayName = name } - get sourceInfo() { return this._sourceInfo ??= sourceTree(this.sources); } + get path(): string { return getPath(this) } - protected clearSourceInfo() { - delete this._sourceInfo; - for (let observer of this._observers) - if (observer instanceof Observable) - observer.clearSourceInfo(); + getSources() { return emptyArray as Iterable> } + + get value() { + startSourceAccessed(this) + endSourceAccessed(this) + return this._value } - get isObserved() { return this._observers.size > 0; } + setValue(newValue: T) { + if (process.env.NODE_ENV !== 'production') { + if (isEventsClass(newValue) || newValue instanceof Observable && !newValue.isValidAsState) + throw new InvalidStateError(this, newValue) - subscribe(observe: Observe, getObserverName = anonymousObserverName): Unsubscribe { - let observer = { getDisplayName: getObserverName, next: observe }; - this._observers.add(observer); - return this.unsubscribe.bind(this, observer); + if (!iterationSource) + throw new StateChangeOutsideTransitionError(this) + } + + if (!this.skipUpdate(newValue)) { + this.lastUpdated = currentIteration + if (isObject(this._value) || isObject(newValue)) + this.updateChildren(this._value, newValue) + this._value = newValue + + for (let observer of this._observers) + observer.sourceUpdated(this, this._value) + } } - protected unsubscribe(observer: IObserver) { - this._observers.delete(observer); + protected skipUpdate(nextValue: T) { return Object.is(this._value, nextValue) } + + protected updateChildren(previousValue: T, nextValue: T) { updateChildren(this, previousValue, nextValue) } + + protected isValidAsState = true + + map, Out>(this: S, transform: (value: T) => Out, displayName = `${this.displayName}.map(${nameOfCallback(transform)})`): IObservable { + return bind(displayName, this, transform) } - [dispose]() { - this.unsubscribeFromSources(); - this._observers.clear(); + filter>(this: S, predicate: (value: T) => boolean, displayName = `${this.displayName}.filter(${nameOfCallback(predicate)})`): IObservable { + return bind(displayName, this, identity, predicate) } - protected notifyObservers(value: T) { - Array.from(this._observers).forEach(o => o.next(value)); + filterMap, Out>(this: S, transform: (value: T) => Out | undefined, displayName = `${this.displayName}.filterMap(${nameOfCallback(transform)})`): IObservable { + return bind(displayName, this, v => transform(v)!, value => value != undefined) } - unsubscribeFromSources() { } + subscribe(observer: IObserver) { this._observers.add(observer) } + unsubscribe(observer: IObserver) { this._observers.delete(observer) } - filter(condition: (value: T) => boolean, getDisplayName: () => string = () => filteredName(this.displayName, condition)): IObservable { - return new ObservableOperation(getDisplayName, [this], - observer => this.subscribe(value => condition(value) && observer.next(value), getDisplayName)); + [dispose]() { + this.updateChildren(this._value, undefined!) + this._value = undefined! + this._observers.clear() } +} - map(select: (value: T) => U, getDisplayName: () => string = () => `${this.displayName}.map(${nameOfCallback(select)})`): IObservable { - return new ObservableOperation(getDisplayName, [this], - observer => this.subscribe(value => observer.next(select(value)), getDisplayName)); - } +export function bind(displayName: string, observable: IObservable, transform: (value: In) => Out, predicate?: (value: Out) => boolean) { + return new BoundObservable(displayName, [observable], transform, predicate) +} - /** Maps values to a new type, filtering out undefined results. */ - filterMap(select: (value: T) => U | undefined, getDisplayName: () => string = () => `${this.displayName}.filterMap(${nameOfCallback(select)})`): IObservable { - return new ObservableOperation(getDisplayName, [this], - observer => this.subscribe(value => { - let mapped = select(value); - if (mapped !== undefined) - observer.next(mapped); - }, getDisplayName)); +export function merge[]>(observables: Observables): IObservable> +export function merge[]>(displayName: string, observables: Observables): IObservable> +export function merge[]>(displayNameOrObservables: string | Observables, maybeObservables?: Observables): IObservable> { + let displayName: string + let observables: Observables + if (typeof displayNameOrObservables == 'string') { + displayName = displayNameOrObservables + observables = maybeObservables! + } else { + observables = displayNameOrObservables + displayName = `merge(${observables.map(s => s.displayName).join(', ')})` } + return new BoundObservable(displayName, observables.slice(), identity) } -export class ObservableOperation extends Observable { - private _unsubscribeFromSources?: Unsubscribe; - +export class BoundObservable extends Observable { constructor( - getDisplayName: () => string, - private _sources: readonly IObservable[], - private readonly _subscribeToSources: (observer: IObserver) => Unsubscribe - ) { - super(getDisplayName); - } + displayName: string, + protected _sources: IObservable[], + protected _transform: (value: In) => Out, + protected _predicate?: (value: Out) => boolean + ) { super(displayName) } + + override getSources() { return this._sources } - override get sources() { return this._sources; } + override subscribe(observer: IObserver): void { + if (!this._observers.size) + this._sources.forEach(source => source.subscribe(this)) - override subscribe(observer: Observe, getObserverName = anonymousObserverName): Unsubscribe { - let unsubscribe = super.subscribe(observer, getObserverName); - if (this._observers.size == 1) - this._unsubscribeFromSources = this._subscribeToSources({ getDisplayName: this.displayNameGetter, next: this.notifyObservers.bind(this) }); - return unsubscribe; + super.subscribe(observer) } - protected override unsubscribe(observer: IObserver) { - super.unsubscribe(observer); + override unsubscribe(observer: IObserver): void { + super.unsubscribe(observer) + if (!this._observers.size) - this.unsubscribeFromSources(); + this._sources.forEach(source => source.unsubscribe(this)) } - override unsubscribeFromSources() { - this._unsubscribeFromSources?.(); + sourceUpdated(source: IObservable, value: In): void { + try { + this.setValue(this._transform(value)) + } finally { + this._value = undefined! // Bound sources shouldn't hold on to their values + } } + + protected override updateChildren(previousValue: Out, nextValue: Out) { } // Value won't be held onto + + protected override skipUpdate(value: Out) { return this._predicate?.(value) == false } } -export function allSources(sources: Iterable>) { - let allSources = new Set>(); - addSourcesRecursive(sources); - return allSources; - - function addSourcesRecursive(sources: Iterable>) { - for (let s of sources) { - if (!allSources.has(s)) { - allSources.add(s); - addSourcesRecursive(s.sources); - } - } - } +export function* allSources(observable: IObservable): Generator> { + yield observable + for (let dependency of observable.getSources()) + yield* allSources(dependency) } -export function pathToSource(leaves: Iterable>, root: IObservable): IObservable[] | undefined { - for (let l of leaves) { - if (l == root) - return [l]; - let path = pathToSource(l.sources, root); - if (path) - return path.concat([l]); +export class NoUnderlyingObservableError extends Error { + constructor( + public value: unknown + ) { + super("Couldn't detect observable for value. Make sure you pass in an observable value directly.") } } -export function isObservable(maybeObservable: any): maybeObservable is IObservable { - return maybeObservable && typeof maybeObservable.subscribe == 'function'; -} \ No newline at end of file +export class InvalidStateError extends Error { + constructor( + public observable: IObservable, + public value: IEventFunction | object + ) { super(`Tried to store ${isObservable(value) ? value.displayName : value} in ${observable.displayName}. ${nameOfFunction(value.constructor)}s cannot be state.`) } +} + +export class StateChangeOutsideTransitionError extends Error { + constructor( + public source: IObservable + ) { + super(`Tried to change state of ${source.displayName} outside of a transition. ` + + 'This usually means a bug in event-reduce, or in a custom observable implementation.') + } +} diff --git a/packages/event-reduce/src/observableValue.ts b/packages/event-reduce/src/observableValue.ts deleted file mode 100644 index 1bdd5d5..0000000 --- a/packages/event-reduce/src/observableValue.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { changeOwnedValue } from "./cleanup"; -import { allSources, IObservable, Observable, pathToSource } from "./observable"; -import { Subject } from "./subject"; -import { Action } from "./types"; -import { constant, dispose, firstIntersection, using } from "./utils"; - -let valueAccessed: Subject; -let lastValueConsumed: Subject; -let lastAccess: ValueAccess | undefined; -let triggeringSources = new Set>; -let triggeringObservable: IObservable | undefined; -let latestVersion = 0; // Shared latest version, so no need to track version per source -const accessedObservableValuesName = constant("(accessed observable values)"); -const consumedLastObservableValueName = constant("(consumed last observable value)"); -const lastAccessedName = constant("(last accessed)"); - -startTrackingScope(); - -export interface IObservableValue extends IObservable { - readonly value: T; -} - -export class ObservableValue extends Observable implements IObservableValue { - private readonly _unsettled = new Subject(() => `${this.displayName}.unsettled`); - private _version = 0; - - constructor( - getDisplayName: () => string, - protected _value: T, - private _valuesEqual: (previous: T, next: T) => boolean = (a, b) => a === b - ) { - super(getDisplayName); - changeOwnedValue(this, undefined, _value); - } - - container?: any; - - /** Fired when a source value has changed, and this value _may_ not be up-to-date any more. */ - get unsettled() { return this._unsettled as IObservable; } - - /** Increased whenever the value changes. Latest version is shared, so this won't increase by just 1. */ - get version() { return this._version; } - - get value() { - if (process.env.NODE_ENV !== 'production') { - let commonSource = triggeringSources.size - ? firstIntersection(triggeringSources, allSources([this])) - : undefined; - - valueAccessed.next(commonSource - ? new AccessedValueWithCommonSourceError(commonSource, triggeringObservable!, this) - : this); - } else { - valueAccessed.next(this); - } - - return this._value; - } - - setValue(value: T) { - if (!this._valuesEqual(this._value, value)) { - changeOwnedValue(this, this._value, value); - this._value = value; - this._version = ++latestVersion; - this.notifyObserversUnsettled(); - this.notifyObservers(value); - } - } - - protected notifyObserversUnsettled() { this._unsettled.next(); } - - override[dispose]() { - super[dispose](); - changeOwnedValue(this, this._value, undefined); - // Keep the value, in case this is still being held onto (e.g. with useDeferredValue) - } -} - -export function collectAccessedValues(action: Action) { - let accessedValues: Set>; - using(valueAccessCollection(), collector => { - action(); - accessedValues = new Set(collector.accesses.map(accessedObservable)); - }); - return accessedValues!; -} - -export function protectionAgainstAccessingValueWithCommonSource(currentSource = triggeringObservable): Disposable { - if (process.env.NODE_ENV !== 'production' && currentSource) { - let outerTriggeringObservable = triggeringObservable; - let outerTriggeringSources = triggeringSources; - triggeringObservable = currentSource; - triggeringSources = new Set( - Array.from(outerTriggeringSources) - .concat(Array.from(allSources([currentSource])))); - let collector = valueAccessCollection(); - - return { - [dispose]() { - collector[dispose](); - triggeringObservable = outerTriggeringObservable; - triggeringSources = outerTriggeringSources; - - let invalidAccessError = collector.accesses.find(isAccessError); - if (invalidAccessError) - throw invalidAccessError; - } - } - } else { - return startTrackingScope(); // Still need to create a new scope so accesses aren't picked up by outer scope - } -} - -const accessedValueCollectionName = constant("(accessed value collection)"); - -function valueAccessCollection() { - let accesses = [] as ValueAccess[]; - let unsubscribeFromAccessed = valueAccessed.subscribe(accesses.push.bind(accesses), accessedValueCollectionName); - let unsubscribeFromConsumed = lastValueConsumed.subscribe(accesses.pop.bind(accesses)); - - return { - accesses, - [dispose]() { - unsubscribeFromAccessed(); - unsubscribeFromConsumed(); - } - } -} - -export function startTrackingScope(): Disposable { - let outerValueAccessed = valueAccessed; - let outerLastValueConsumed = lastValueConsumed; - - valueAccessed = new Subject(accessedObservableValuesName); - lastValueConsumed = new Subject(consumedLastObservableValueName); - let unsubscribeLastAccessed = valueAccessed.subscribe(setLastAccess, lastAccessedName); - - return { - [dispose]() { - unsubscribeLastAccessed(); - lastValueConsumed = outerLastValueConsumed; - valueAccessed = outerValueAccessed; - } - } -} - -function setLastAccess(access: ValueAccess) { - lastAccess = access; -} - -/** Allows subscribing to the changes of the specified observable value. */ -export function valueChanged(observableValue: T) { - let observable = getUnderlyingObservable(observableValue); - if (observable) - return observable; - throw new ValueIsNotObservableError(observableValue); -} - -export function getUnderlyingObservable(value: T): ObservableValue | undefined { - let lastAccessed = consumeLastAccessed(); - if (lastAccessed) { - let observableValue: unknown; - using(startTrackingScope(), () => { observableValue = lastAccessed.value; }); - if (observableValue == value) - return lastAccessed; - } -} - -export function consumeLastAccessed() { - if (lastAccess) { - let consumed = lastAccess; - lastValueConsumed.next(); - lastAccess = undefined; - return accessedObservable(consumed); - } -} - -type ValueAccess = ObservableValue | IAccessedObservablueError; -interface IAccessedObservablueError extends Error { readonly accessedObservable: ObservableValue }; - -function isAccessError(value: ValueAccess): value is IAccessedObservablueError { - return (value as IAccessedObservablueError).accessedObservable != undefined; -} - -function accessedObservable(value: ValueAccess): ObservableValue { - return (value as IAccessedObservablueError).accessedObservable ?? value; -} - -export class ValueIsNotObservableError extends Error { - constructor( - public value: unknown - ) { - super("Couldn't detect observable value. Make sure you pass in an observable value directly."); - } -} - -export class AccessedValueWithCommonSourceError extends Error implements IAccessedObservablueError { - constructor( - public commonSource: IObservable, - public triggeringObservable: IObservable, - public accessedObservable: ObservableValue - ) { - super(`Accessed an observable value derived from the same event being fired. -Fired: ${pathToSource([triggeringObservable], commonSource)!.map(o => o.displayName).join(' -> ')} -Accessed: ${pathToSource([accessedObservable], commonSource)!.map(o => o.displayName).join(' -> ')}`); - } -} \ No newline at end of file diff --git a/packages/event-reduce/src/ownership.ts b/packages/event-reduce/src/ownership.ts new file mode 100644 index 0000000..c88d041 --- /dev/null +++ b/packages/event-reduce/src/ownership.ts @@ -0,0 +1,151 @@ +import { log } from "./logging" +import { type IObservable, isObservable } from "./observable" +import { type EventsClass, isEventsClass, isModel, isStateProperty, type Model } from "./symbols" +import { dispose, emptyArray, emptyMap, isDisposable, isObject, nameOfFunction } from "./utils" + +type GraphNode = IObservable | Model | EventsClass + +/** Child -> parent -> path. */ +let parentRegistry = new WeakMap>() +/** Value -> node -> path. */ +let childSnapshots = new WeakMap>() +let garbage = new Set() + +let currentCreationScope: Set> | null = null + +export function updateChildren(parent: GraphNode, previousValue: unknown, nextValue: unknown, parentPath = '') { + if (Object.is(previousValue, nextValue)) + return + + if (!isObject(previousValue) && isGraphNode(nextValue)) { + addParent(nextValue, parent, parentPath) // Fast-track for adding single node + return + } + + if (!isObject(nextValue) && isGraphNode(previousValue)) { + removeParent(previousValue, parent) // Fast-track for removing single node + return + } + + let previousChildren: ReadonlyMap = emptyMap + if (isObject(previousValue)) { + previousChildren = childSnapshots.get(previousValue) ?? getChildNodes(previousValue) + childSnapshots.delete(previousValue) + } + + let newChildren: ReadonlyMap = emptyMap + if (isObject(nextValue)) { + newChildren = getChildNodes(nextValue) + childSnapshots.set(nextValue, newChildren) + } + + for (let [removed, _] of previousChildren) + if (!newChildren.has(removed)) + removeParent(removed, parent) + + for (let [added, path] of newChildren) + if (!previousChildren.has(added)) + addParent(added, parent, parentPath + path) +} + +export function getChildNodes(value: object): ReadonlyMap { + if (isGraphNode(value)) + return new Map([[value, '']]) + + if (isStateProperty(value)) + return isObject(value.value) + ? new Map(getChildNodes(value.value) + .entries() + .map(([node, path]) => [node, `.${String(value.key)}${path}`])) + : emptyMap + + if (Array.isArray(value)) + return new Map(value + .filter(isGraphNode) + .map((obj, i) => [obj, `[${i}]`])) + + return new Map(Object.entries(value) + .filter(([_, val]) => isGraphNode(val)) + .map(([key, val]) => [val, `.${key}`])) +} + +function isGraphNode(value: unknown): value is GraphNode { + return isObservable(value) + || isModel(value) + || isEventsClass(value) +} + +function addParent(object: GraphNode, owner: GraphNode, path: string) { + let parents = parentRegistry.get(object) + + if (!parents) + parentRegistry.set(object, parents = new Map()) + + parents.set(owner, path) +} + +function removeParent(node: GraphNode, parent: GraphNode) { + let parents = parentRegistry.get(node) + if (parents) { + parents.delete(parent) + + if (!parents.size && isDisposable(node)) { + parentRegistry.delete(node) + garbage.add(node) + } + } +} + +export function disposeGarbage() { + for (let node of garbage) { + if (!parentRegistry.has(node)) { + log('🗑️ (dispose)', getName(node), emptyArray, () => ({ Path: getPath(node) })) + node[dispose]() + } + } + garbage.clear() +} + +export interface IManagedObservableTracker extends Disposable { + verifyObservableManagement(): void +} + +export function trackManagedObservableCreation(): IManagedObservableTracker { + let previousScope = currentCreationScope + let currentScope = currentCreationScope = new Set() + return { + verifyObservableManagement() { + for (let object of currentScope) + if (!parentRegistry.has(object)) + throw new UntrackedObservableError(object) + }, + [dispose]() { currentCreationScope = previousScope } + } +} + +export function managedObservableCreated(observable: GraphNode & IObservable) { + if (!currentCreationScope) + throw new UntrackedObservableError(observable) + currentCreationScope.add(observable) +} + +export function getPath(node: GraphNode): string { + let primaryOwner = parentRegistry.get(node)?.entries().next().value + return primaryOwner + ? getPath(primaryOwner[0]) + primaryOwner[1] + : getName(node) +} + +function getName(node: GraphNode) { + return isObservable(node) ? node.displayName + : isObject(node) ? nameOfFunction(node.constructor) || '$' + : '$' +} + +export class UntrackedObservableError extends Error { + constructor(observable: IObservable) { + super(`Leak detected: ${nameOfFunction(observable.constructor)} '${observable.displayName}' created without an owner. +Make sure reductions are defined on a model, and that the model is either a class decorated with @model, or created using createModel().`) + } +} + diff --git a/packages/event-reduce/src/reactions.ts b/packages/event-reduce/src/reactions.ts deleted file mode 100644 index 646e31f..0000000 --- a/packages/event-reduce/src/reactions.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Action, Unsubscribe } from "./types"; - -export interface IReactionQueue { - add(reaction: Action): Unsubscribe; -} - -export class MicrotaskQueue implements IReactionQueue { - add(reaction: Action) { - let cancelled = false; - - queueMicrotask(function runReaction() { - if (!cancelled) - reaction(); - }); - - return () => cancelled = true; - } -} - -export const reactionQueue = { - /** Can be replaced for testing purposes */ - current: new MicrotaskQueue() as IReactionQueue -}; \ No newline at end of file diff --git a/packages/event-reduce/src/reduction.ts b/packages/event-reduce/src/reduction.ts index 60f194c..805b0be 100644 --- a/packages/event-reduce/src/reduction.ts +++ b/packages/event-reduce/src/reduction.ts @@ -1,138 +1,154 @@ -import { EventFn, IEventClass, isEvent } from "./events"; -import { log, sourceTree } from "./logging"; -import { isEventsClass } from "./models"; -import { IObservable, allSources, isObservable } from "./observable"; -import { IObservableValue, ObservableValue, protectionAgainstAccessingValueWithCommonSource, valueChanged } from "./observableValue"; -import { State, setState } from "./state"; -import { Subject } from "./subject"; -import { Unsubscribe } from "./types"; -import { emptyArray, isObject, using } from "./utils"; - -export function reduce(initial: TValue, displayName?: string): IReduction; -export function reduce(initial: TValue, events: TEvents, displayName?: string): IBoundReduction; -export function reduce(initial: TValue, events?: TEvents | string, displayName: string = '(anonymous reduction)'): IReduction { - return events && typeof events != 'string' ? new BoundReduction(() => displayName, initial, events) - : typeof events == 'string' ? new Reduction(() => events, initial) - : new Reduction(() => displayName, initial); +import { Derivation } from "./derivation" +import { startLogging } from "./logging" +import { allSources, IObservable, IObserver, isObservable, NoUnderlyingObservableError, Observable } from "./observable" +import { managedObservableCreated, updateChildren } from "./ownership" +import { ModelJSON, modelJSON } from "./state" +import { collectAccessedSources, currentIteration, endSourceAccessed, getUnderlyingObservable, startSourceAccessed } from "./tracking" +import { dispose, emptyArray } from "./utils" + +export interface IReduction extends IObservable { + readonly value: T + on(getSource: (events: Events) => IObservable, reduce: Reducer): this + on(source: IObservable, reduce: Reducer): this } -type Reducer = (previous: TValue, eventValue: TEvent) => TValue; +export function reduce(initialValue: T, displayName?: string): IReduction +export function reduce(initialValue: T, events: Events, displayName?: string): IReduction +export function reduce(initialValue: T, eventsOrDisplayName?: Events | string, maybeDisplayName?: string): IReduction { + let [events, displayName] = typeof eventsOrDisplayName == 'string' + ? [undefined as Events, eventsOrDisplayName] + : [eventsOrDisplayName, maybeDisplayName] -export interface IReduction extends IObservableValue { - on(observable: IObservable, reduce: Reducer): this; - /** @deprecated use valueChanged function instead */ - onValueChanged(observableVaue: TValue, reduce: Reducer): this; - onRestore(reduce: Reducer>): this; + return new Reduction(displayName ?? Reduction.defaultName, initialValue, Object.is, events) } -export interface IBoundReduction extends IReduction { - on(observable: ((events: TEvents) => IObservable) | IObservable, reduce: Reducer): this; +/** Extends an existing Observable with reduction capabilities. */ +export function extendReduce(observableValue: T, displayName?: string): IReduction { + let observable = getUnderlyingObservable(observableValue) + if (observable) + return reduce(observableValue, displayName ?? observable.displayName).on(observable, (_, val) => val) + throw new NoUnderlyingObservableError(observableValue) } -export class Reduction extends ObservableValue implements IReduction { - private _sources = new Map, Unsubscribe>(); - private _restore = new RestoreSubject>(() => `${this.displayName}.restored`); +export type Reducer = (current: Result, next: Next) => Result - constructor(getDisplayName: () => string, initial: T) { - super(getDisplayName, initial); +export class Reduction extends Observable implements IReduction, IObserver { + private readonly _sources = new Set>() + private _singleReducer?: (current: T, next: any) => T + private _reducers = new Map, (current: T, next: any) => T>() + private _sourcesLastChecked = -1 + private _json: Derivation> + static defaultName = "(anonymous reduction)" - this.onRestore((current, state) => { - if (isObject(current)) { - setState(current, state); - return current; - } - return state as T; - }); - } + constructor( + displayName: string, + initialValue: T, + private _isEqual = Object.is, + private _events?: Events + ) { + super(displayName) + + this._json = new Derivation(`toJSON(${this.displayName})`, () => modelJSON(this.value)) + this._json.suppressLogging = true - override get sources() { return Array.from(this._sources.keys()); } + managedObservableCreated(this) - restore(state: State): void { - this._restore.next(state); + this._value = initialValue + updateChildren(this, null, initialValue) } - on(observable: IObservable, reduce: Reducer) { + toJSON() { return this._json.value } + + on(source: IObservable, reduce: Reducer): this + on(getSource: (events: Events) => IObservable, reduce: Reducer): this + on(source: IObservable | ((events: Events) => IObservable), reduce: Reducer) { + if (!isObservable(source)) + source = source(this._events!) + if (process.env.NODE_ENV !== 'production') { - if (allSources(observable.sources).has(this)) - throw new CircularSubscriptionError(this, observable); + if (allSources(source).find(s => s == this)) + throw new CircularSubscriptionError(this, source) } - let unsubscribeExisting = this._sources.get(observable); - if (unsubscribeExisting) - unsubscribeExisting(); - - this._sources.set(observable, observable.subscribe(eventValue => { - let previousValue = this._value; - let value!: T; - - log('🧪 (reduction)', this.displayName, emptyArray, () => ({ - Previous: previousValue, - Current: value, - Container: this.container, - Sources: sourceTree(this.sources) - }), () => { - if (process.env.NODE_ENV !== 'production') { - using(protectionAgainstAccessingValueWithCommonSource(observable), () => { - value = reduce(this._value, eventValue); - }); - - if (isEvent(value) || isEventsClass(value)) - throw new ReducedEventsError(this, value); - } else { - value = reduce(this._value, eventValue); + source.subscribe(this) + this._sources.add(source) + this._reducers.set(source, reduce) + this._singleReducer = this._reducers.size == 1 ? reduce : undefined + this._sourcesLastChecked = Math.max(this._sourcesLastChecked, source.lastUpdated) // Avoid firing reduction because of new source + + return this + } + + override getSources() { return this._sources } + + sourceUpdated(source: IObservable, value: S) { + if (source.lastUpdated > this.lastUpdated) { + let reduce = this._singleReducer ?? this._reducers.get(source) + if (reduce) { + let previousValue = this._value + let nextValue!: T + let logging = startLogging('🧪 (reduction)', this.displayName, emptyArray, () => ({ + Path: this.path, + Trigger: source.path, + Previous: previousValue, + Current: nextValue + })) + + try { + let tracker = collectAccessedSources(this) // Required for detecting circular references in reducers + try { + nextValue = reduce(this._value, value) + } finally { + tracker[dispose]() + } + + this.setValue(nextValue) + } finally { + logging[dispose]() } + } + } + } - this.setValue(value) - }); - }, this.displayNameGetter)); + override get value() { + startSourceAccessed(this) // Notifying tracker before checking for updates so circular reference is detected early - this.clearSourceInfo(); + try { + if (this._sourcesLastChecked < currentIteration) { + let newerSource = this._sources.values().find(s => s.lastUpdated > this._sourcesLastChecked) + if (newerSource) + this.sourceUpdated(newerSource, (newerSource as Observable).value) + } + } finally { + endSourceAccessed(this) + } - return this; + return this._value // Not using super.value to avoid duplicate tracker notification } - onValueChanged(observableValue: TValue, reduce: Reducer) { - return this.on(valueChanged(observableValue), reduce); + override setValue(value: T) { + this._sourcesLastChecked = currentIteration + super.setValue(value) } - onRestore(reduce: Reducer>): this { - return this.on(this._restore, reduce); - } + override skipUpdate(nextValue: T) { return this._isEqual(this._value, nextValue) } - override unsubscribeFromSources() { - this._sources.forEach(unsub => unsub()); - this._sources.clear(); + detach() { + for (let source of this._sources) + source.unsubscribe(this) } -} - -class BoundReduction extends Reduction implements IBoundReduction { - constructor( - getDisplayName: () => string, - initial: TValue, - private _events: TEvents = {} as TEvents - ) { super(getDisplayName, initial); } - override on(observable: ((events: TEvents) => IObservable) | IObservable, reduce: Reducer): this { - return super.on(isObservable(observable) ? observable : observable(this._events), reduce); + override[dispose]() { + this.detach() + super[dispose]() } } -export class RestoreSubject extends Subject { } - export class CircularSubscriptionError extends Error { constructor( - public reduction: IReduction, - public observable: IObservable + public reduction: IReduction, + public source: IObservable ) { - super(`Cannot subscribe to '${observable.displayName}', as it depends on this reduction, '${reduction.displayName}'.`); + super(`Cannot subscribe to '${source.displayName}', as it depends on this reduction, '${reduction.displayName}'.`) } } - -export class ReducedEventsError extends Error { - constructor( - public reduction: IReduction, - public value: EventFn | object - ) { - super(`Reduction ${reduction.displayName} reduced to event or events class ${isEvent(value) ? value.displayName : value}. Events cannot be state.`); - } -} \ No newline at end of file diff --git a/packages/event-reduce/src/signal.ts b/packages/event-reduce/src/signal.ts new file mode 100644 index 0000000..55dbf39 --- /dev/null +++ b/packages/event-reduce/src/signal.ts @@ -0,0 +1,46 @@ +import { Derivation } from "./derivation" +import { IObservable, Observable } from "./observable" +import { updateChildren } from "./ownership" +import { ModelJSON, modelJSON } from "./state" +import { beginTransition } from "./tracking" +import { dispose, isObject } from "./utils" + +export interface ISignal extends IObservable { + readonly value: T + setValue(newValue: T): void +} + +export function signal(initialValue: T, displayName?: string): ISignal { + return new Signal(displayName ?? "(anonymous value)", initialValue) +} + +export class Signal extends Observable implements IObservable { + private _json: Derivation> + + constructor( + displayName: string, + initialValue: T, + ) { + super(displayName) + this._json = new Derivation(`toJSON(${this.displayName})`, () => modelJSON(this.value)) + this._json.suppressLogging = true + + this._value = initialValue + updateChildren(this, undefined, initialValue) + } + + toJSON() { return this._json.value } + + override get value() { return super.value } + + override setValue(value: T) { + let transition = beginTransition(this, isObject(value) ? value : { value }) + try { + super.setValue(value) + } finally { + transition[dispose]() + } + } + + override skipUpdate(nextValue: T) { return false } +} diff --git a/packages/event-reduce/src/state.ts b/packages/event-reduce/src/state.ts index 4089d75..d769565 100644 --- a/packages/event-reduce/src/state.ts +++ b/packages/event-reduce/src/state.ts @@ -1,129 +1,35 @@ -import { getObservableValues, getStateProperties, isModel } from "./models"; -import { Reduction } from "./reduction"; -import { OmitValues, StringKey } from "./types"; -import { isObject, jsonPath } from "./utils"; - -export type State = - T extends Function ? never - : T extends Array ? State[] - : T extends object ? { [K in StringKey>]: State } - : T; - -export type StateKey = Extract>; - -export interface IStateOptions { - /** - * Name of root model, used for errors - * @default $ - **/ - name?: string; - /** @default false */ - includeDerivedProperties?: boolean; - /** @default 'expect' in production, otherwise 'error' */ - circularReferences?: CircularReferenceHandling; -} - -export type CircularReferenceHandling = 'error' | 'warn' | 'expect'; - -export function getState(model: T, options: IStateOptions = {}): State { - return getStateRecursive(options, new Map(), options.name ?? '$', model); -} - -function getStateRecursive(options: IStateOptions, parentPath: Map, key: PropertyKey, model: T): State { - if (!isObject(model)) - return model as State; - - if (parentPath.has(model)) - return getCircularReference(options.circularReferences ?? (process.env.NODE_ENV === 'production' ? 'expect' : 'error'), parentPath, key, model); - - parentPath.set(model, key); - let state = Array.isArray(model) - ? getArrayState(options, parentPath, model) - : getObjectState(options, parentPath, model); - parentPath.delete(model); - return state; -} - -function getArrayState(options: IStateOptions, parentPath: Map, model: T & any[]) { - return model.map((value, key) => getStateRecursive(options, parentPath, key, value)) as State; -} - -function getObjectState(options: IStateOptions, parentPath: Map, model: T) { - let state = {} as State; - for (let key of getAllStatefulProperties(model, options.includeDerivedProperties)) { - let value = model[key]; - if (typeof value != 'function') - state[key] = getStateRecursive(options, parentPath, key, value) as State[StateKey]; - } - return state; -} - -function getCircularReference(handling: CircularReferenceHandling, parentPath: Map, key: PropertyKey, model: T) { - let circularPath = Array.from(parentPath) - .map(([value, key]) => [key, value] as [PropertyKey, unknown]) - .concat([[key, model]]); - - let circularPathKeys = circularPath.map(([key]) => key); - let referencedPathKeys = circularPathKeys.slice(0, circularPath.findIndex(([, v]) => v == model) + 1); - - if (handling == 'warn') - console.warn(CircularReferenceInStateError.message(circularPathKeys, referencedPathKeys), new Details(circularPath)); - - if (handling == 'error') - throw new CircularReferenceInStateError(circularPath, circularPathKeys, referencedPathKeys); - - return `` as State; -} - -// Just for naming in the console -class Details { constructor(public circularPath: (readonly [PropertyKey, unknown])[]) { } } - -export function setState(model: T, state: State) { - let observableProps = getReducedProperties(model); - let stateProps = getAllStatefulProperties(model) - .filter(key => key in (state as object)); - - stateProps.forEach(key => { - if (key in observableProps) - observableProps[key].restore(state[key]); - else if (isObject(model[key])) - setState(model[key], state[key] as State]>); - else - model[key] = state[key] as T[StateKey]; - }); - - return model; -} - -function getAllStatefulProperties(model: T, includeDerived = false): StateKey[] { - if (!isModel(model)) - return Object.keys(model as object) as StateKey[]; - let observableProps = includeDerived - ? Object.keys(getObservableValues(model) || {}) - : Object.keys(getReducedProperties(model)); - let explicitProps = getStateProperties(model); - return observableProps.concat(explicitProps) as StateKey[]; -} - -function getReducedProperties(model: T) { - let reducedProps = {} as Record, Reduction>; - for (let [key, observableValue] of Object.entries(getObservableValues(model))) { - if (observableValue instanceof Reduction) - reducedProps[key as StateKey] = observableValue; - } - return reducedProps; -} - -export class CircularReferenceInStateError extends Error { - constructor( - public circularPath: ([PropertyKey, unknown])[], - public circularPathKeys: PropertyKey[], - public referencedPathKeys: PropertyKey[] - ) { - super(CircularReferenceInStateError.message(circularPathKeys, referencedPathKeys)); - } - - static message(circularPathKeys: PropertyKey[], referencedPathKeys: PropertyKey[]) { - return `Detected circular reference in model: ${jsonPath(circularPathKeys)} -> ${jsonPath(referencedPathKeys)}`; - } -} \ No newline at end of file +import { ISignal, signal } from "./signal" +import { isObject } from "./utils" + +export type State = T extends { toJSON(): infer S } + ? State + : (T extends object + ? { [K in keyof T]: State } + : T) + +export function getState(model: T, includeDerived = false): State { + return JSON.parse(stringifyState(model, includeDerived)) +} + +export function stringifyState(model: T, includeDerived = false): string { + let config = includeDerivedState() + if (config.value != includeDerived) + config.setValue(includeDerived) + return JSON.stringify(model) +} + +let includeDerivedStateSignal: ISignal | undefined + +export function includeDerivedState() { + return includeDerivedStateSignal ??= signal(false, 'includeDerivedState') +} + +export type ModelJSON = T extends { toJSON(): infer S } + ? ModelJSON + : T + +export function modelJSON(model: T): ModelJSON { + return isObject(model) && typeof (model as any).toJSON == 'function' + ? (model as any).toJSON() + : model as ModelJSON +} diff --git a/packages/event-reduce/src/subject.ts b/packages/event-reduce/src/subject.ts deleted file mode 100644 index 8d6db34..0000000 --- a/packages/event-reduce/src/subject.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { IObservable, Observable } from "./observable"; -import { constant } from "./utils"; - -export interface ISubject extends IObservable { - next(value: T): void; -} - -const anonymousSubjectName = constant("(anonymous subject)"); - -export class Subject extends Observable implements ISubject { - constructor(getDisplayName: () => string = anonymousSubjectName) { super(getDisplayName); } - next(value: T) { this.notifyObservers(value); } -} diff --git a/packages/event-reduce/src/symbols.ts b/packages/event-reduce/src/symbols.ts new file mode 100644 index 0000000..453033f --- /dev/null +++ b/packages/event-reduce/src/symbols.ts @@ -0,0 +1,51 @@ +import { isObject } from "./utils" + +const eventBrand = Symbol('event') +const eventsBrand = Symbol('events class') + +export const modelBrand = Symbol('model class') +export const modelProperties = Symbol('model properties') + +export const statePropertyBrand = Symbol('state property') + +export interface Model extends Disposable { + [modelProperties]: Record +} + +export interface EventsClass { + [eventsBrand]: true +} + +export interface StatePropertyBrand { + [statePropertyBrand]: true + key: PropertyKey + value: unknown +} + +export function markEvent(value: Function) { + (value as any)[eventBrand] = true +} + +export function isEvent(value: any): value is Function { + return typeof value == 'function' + && eventBrand in value +} + +export function markEventsClass(value: object) { + (value as any)[eventsBrand] = true +} + +export function isEventsClass(value: unknown): value is EventsClass { + return isObject(value) + && eventsBrand in value +} + +export function isModel(value: unknown): value is Model { + return isObject(value) + && modelProperties in value +} + +export function isStateProperty(value: unknown): value is StatePropertyBrand { + return isObject(value) + && statePropertyBrand in value +} diff --git a/packages/event-reduce/src/testing.ts b/packages/event-reduce/src/testing.ts index 01ee0be..29e700c 100644 --- a/packages/event-reduce/src/testing.ts +++ b/packages/event-reduce/src/testing.ts @@ -1,85 +1,105 @@ -import { event } from "./events"; -import { getObservableValue } from "./models"; -import { ObservableValue } from "./observableValue"; -import { getAllPropertyDescriptors, isObject } from "./utils"; +import { isModel } from "./symbols" +import { Derivation } from "./derivation" +import { event } from "./events" +import { getModelProperties } from "./models" +import { isObservable, Observable } from "./observable" +import { Signal } from "./signal" +import { Reduction } from "./reduction" +import { beginTransition } from "./tracking" +import { dispose, isObject } from "./utils" + +const targetAccessor = Symbol('target') export function mutable(model: T): Mutable { - if (isObject(model) && !Array.isArray(model)) { - for (let [key, base] of getAllPropertyDescriptors(model)) { - let observableValue = getObservableValue(model, key)!; + if (isModel(model)) { + let properties = getModelProperties(model) - if (observableValue) { + for (let key in properties) { + let property = properties[key] + if (isObservable(property)) { Object.defineProperty(model, key, { - get() { return observableValue.value; }, + get() { return (property as Observable).value }, set(value) { - observableValue.unsubscribeFromSources(); - observableValue.setValue(value); + if (property instanceof Reduction || property instanceof Derivation) + property.detach() + + if (property instanceof Observable) { + let transition = beginTransition(property, isObject(value) ? value : { value }) + try { + property.setValue(value) + } finally { + transition[dispose]() + } + } }, - enumerable: base.enumerable, + enumerable: Object.getOwnPropertyDescriptor(model, key)?.enumerable ?? true, configurable: true - }); + }) } } + (model as any)[targetAccessor] = model + if (!('target' in model)) - Object.defineProperty(model, 'target', { get: () => model, configurable: true }); + Object.defineProperty(model, 'target', { value: model, configurable: true }) } - return model as Mutable; + return model as Mutable } -export function modelProxy(initialState: T): Mutable; -export function modelProxy(): Mutable; +export function modelProxy(initialState: T): Mutable +export function modelProxy(): Mutable export function modelProxy(model: T = {} as T) { if (!isObject(model) || Array.isArray(model)) - return model; + return model - let observableValues = {} as Record>; + let properties = {} as Record> // Pre-populate observable values with plain value fields Object.entries(Object.getOwnPropertyDescriptors(model)) .forEach(([key, prop]) => { if ('value' in prop) - observableValues[key] = new ObservableValue(() => String(key), prop.value); - }); + properties[key] = new Signal(String(key), prop.value) + }) let proxy: T = new Proxy(model, { get(target: T, key: PropertyKey, receiver: T) { if (key == 'target' && !('target' in target) + || key == targetAccessor ) { - return proxy; + return proxy } - if (observableValues.hasOwnProperty(key)) - return observableValues[key as string].value; + if (properties.hasOwnProperty(key)) + return properties[key as string].value if (key in target) - return Reflect.get(target, key, receiver); + return Reflect.get(target, key, receiver) - return getOrAddObservableValue(key).value; + return getOrAddObservableValue(key).value }, set(target: T, key: PropertyKey, value: any) { - if (key == 'target' || key == 'readonly') - return false; + if (key == 'target') + return false - getOrAddObservableValue(key).setValue(value); - return true; + getOrAddObservableValue(key).setValue(value) + return true }, ownKeys(target: T) { - return Array.from(new Set(Object.keys(target).concat(Object.keys(observableValues)))); + return Array.from(new Set(Object.keys(target).concat(Object.keys(properties)))) }, has(target: T, key: PropertyKey) { return key in target - || key in observableValues; + || key in properties } - }); + }) - return proxy; + return proxy - function getOrAddObservableValue(key: PropertyKey): ObservableValue { - return observableValues[key as string] || (observableValues[key as string] = new ObservableValue(() => String(key), model[key as keyof T])); + function getOrAddObservableValue(key: PropertyKey): Signal { + return properties[String(key)] ??= new Signal(String(key), model[key as keyof T]) } } @@ -89,22 +109,35 @@ export type Mutable = ? {} : { /** The model proxy as the original type */ - readonly target: T; + readonly target: T }) -export function eventProxy(): T; -export function eventProxy void>(createEvent: () => TEvent): { [key: string]: TEvent }; -export function eventProxy void, T = any>(createEvent: () => TEvent): T & { [P in keyof T]: TEvent }; +export function eventProxy(): T +export function eventProxy void>(createEvent: () => TEvent): { [key: string]: TEvent } +export function eventProxy void, T = any>(createEvent: () => TEvent): T & { [P in keyof T]: TEvent } export function eventProxy(createEvent: () => any = event) { - let event = createEvent(); + let event = createEvent() return new Proxy(event, { get(target: any, key: PropertyKey) { if (typeof key == 'string') - return target[key] || (target[key] = eventProxy(createEvent)); + return target[key] || (target[key] = eventProxy(createEvent)) + else if (key == targetAccessor) + return target }, apply(target: any, thisArg: any, argArray?: any) { - event(...argArray); + event(...argArray) } - }); + }) +} + +export function getTarget(proxy: T): T { + let target = (proxy as any)[targetAccessor] + if (!target) + throw new NotAProxyError() + return target +} + +export class NotAProxyError extends Error { + constructor() { super('Object is not a proxy created by event-reduce testing utilities.') } } \ No newline at end of file diff --git a/packages/event-reduce/src/tracking.ts b/packages/event-reduce/src/tracking.ts new file mode 100644 index 0000000..93da2bb --- /dev/null +++ b/packages/event-reduce/src/tracking.ts @@ -0,0 +1,124 @@ +import { sendEvent } from "./devtools" +import { Effect } from "./effect" +import { type IObservable, type IObserver, NoUnderlyingObservableError, Observable } from "./observable" +import { disposeGarbage } from "./ownership" +import { dispose, nullDisposable } from "./utils" + +export let currentIteration = 0 +export let iterationSource: IObservable | Effect | null = null +let transitionEffectQueue = [] as (() => void)[] + +let currentAccessTracker: AccessTracker | null = null +let lastSourceAccessed: IObservable | null = null + +export function beginTransition(source: IObservable | Effect, trigger?: object): Disposable { + if (trigger) { + if (iterationSource) + throw new SideEffectDuringReactionError(iterationSource, source) + + currentIteration++ + iterationSource = source + + return { + [dispose]() { + let effect: undefined | (() => void) + while (effect = transitionEffectQueue.shift()) + effect() + iterationSource = null + disposeGarbage() + sendEvent(source.displayName, trigger) + } + } + } else { + return nullDisposable + } +} + +export function queueTransitionEffect(effect: () => void) { + transitionEffectQueue.push(effect) +} + +export function collectAccessedSources(observer: IObserver | null) { + return new AccessTracker(observer) +} + +export function startSourceAccessed(source: IObservable) { + currentAccessTracker?.addAccessed(source) +} + +export function endSourceAccessed(source: IObservable) { + lastSourceAccessed = source +} + +/** Allows subscribing to the changes of the specified observable value. */ +export function valueChanged(observableValue: T) { + let observable = getUnderlyingObservable(observableValue) + if (observable) + return observable + throw new NoUnderlyingObservableError(observableValue) +} + +export function getUnderlyingObservable(value: T): IObservable | null { + return lastSourceAccessed + && lastSourceAccessed instanceof Observable + && Object.is(lastSourceAccessed.value, value) + ? lastSourceAccessed + : null +} + +export interface IAccessTracker extends Disposable { + readonly accessed: ReadonlySet> +} + +class AccessTracker implements IAccessTracker { + private readonly _previous: AccessTracker | null + readonly accessed = new Set>() + + constructor(private readonly _observer: IObserver | null) { + this._previous = currentAccessTracker + currentAccessTracker = this + } + + addAccessed(source: IObservable) { + if (this._observer + && !this.accessed.has(source) + && this.hasCircularDependency(source) + ) { + throw new CircularDependencyError(this.circularPath(source)) + } + + this.accessed.add(source) + } + + private hasCircularDependency(source: IObservable) { + let foundCircularDependency = false + for (let tracker = this as AccessTracker | null; !foundCircularDependency && tracker; tracker = tracker._previous) + foundCircularDependency = tracker._observer == source as any as IObserver + return foundCircularDependency + } + + private circularPath(source: IObservable) { + let path = [source] + let tracker: AccessTracker | null = this + do { + path.unshift(tracker._observer as any as IObservable) + tracker = tracker._previous + } while (tracker && tracker._observer != source as any as IObserver) + return path + } + + [dispose]() { + currentAccessTracker = this._previous + } +} + +export class CircularDependencyError extends Error { + constructor(path: IObservable[]) { super(`Circular dependency detected: ${path.map(s => s.displayName).join(' -> ')}.`) } +} + +export class SideEffectDuringReactionError extends Error { + constructor( + public reactingTo: IObservable | Effect, + public sideEffect: IObservable | Effect + ) { super(`${sideEffect.displayName} side effect during reaction to ${reactingTo.displayName}.`) } +} diff --git a/packages/event-reduce/src/types.ts b/packages/event-reduce/src/types.ts index 989e95e..a6e6f78 100644 --- a/packages/event-reduce/src/types.ts +++ b/packages/event-reduce/src/types.ts @@ -1,12 +1,7 @@ -export type Scoped = - & Omit - & { [K in keyof Scope]?: T[K]; }; +import type { IObservable } from "./observable" -export type StringKey = Extract; -export type OmitValues = FromEntries, [any, Omitted]>>; -export type FromEntries = { [Entry in Entries as Entry[0]]: Entry[1]; }; -export type Entries = ValueOf<{ [K in keyof T]: [K, T[K]] }>; -export type ValueOf = T[keyof T]; +export type Scoped> = + & Omit + & { [K in keyof Scope]?: K extends keyof T ? T[K] : never } -export type Action = () => void; -export type Unsubscribe = () => void; \ No newline at end of file +export type ObservedValue> = Observable extends IObservable ? T : never diff --git a/packages/event-reduce/src/utils.ts b/packages/event-reduce/src/utils.ts index 5a03180..e045147 100644 --- a/packages/event-reduce/src/utils.ts +++ b/packages/event-reduce/src/utils.ts @@ -1,125 +1,89 @@ -import { Unsubscribe } from "./types"; +export const emptyArray = Object.freeze([]) as readonly any[] -export const emptyArray = Object.freeze([]) as readonly any[]; +export const emptyMap = Object.freeze(new Map()) as ReadonlyMap -export function constant(value: T) { return returnValue.bind(null, value) as () => T; } -function returnValue(value: T) { return value; } - -export function filteredName(baseName: string, predicate: Function) { - return `${baseName}.filter(${nameOfCallback(predicate)})`; -} +export function constant(value: T) { return identity.bind(null, value) as () => T } +export function identity(value: T) { return value } export function scopedName(baseName: string, scope: object) { - return `${baseName}.scoped({ ${Object.entries(scope).map(([k, v]) => `${k}: ${v}`).join(', ')} })`; + return `${baseName}.scoped({ ${Object.entries(scope).map(([k, v]) => `${k}: ${v}`).join(', ')} })` } /** Returns explicit name of function, falling back to function content. */ export function nameOfCallback(fn: Function) { - return nameOfFunction(fn) || String(fn); + return nameOfFunction(fn) || String(fn) } /** Returns explicit name of function. */ export function nameOfFunction(fn: Function) { - return (fn as any).displayName as string || fn.name; -} - -export function jsonPath(keys: readonly PropertyKey[]) { - return keys.map((key, i) => - i == 0 ? key - : typeof key == 'number' || typeof key == 'symbol' ? `[${String(key)}]` - : /^[\w\$]+$/.test(key) ? `.${key}` - : `[${JSON.stringify(key)}]`) - .join(''); + return (fn as any).displayName as string || fn.name } -/** Utility base class for anything with a dynamic displayName */ -export class NamedBase { - constructor(private _getDisplayName: () => string) { } - - get displayName() { return this._getDisplayName(); } - set displayName(name: string) { this._getDisplayName = constant(name); } - - /** Just for avoiding unnecessary arrow functions, but importantly reflects changes to the _getDisplayName callback. */ - protected readonly displayNameGetter = () => this._getDisplayName(); -} - -export function matchesScope(scope: Scope): (value: Value) => boolean; -export function matchesScope(scope: Scope, value: Value): boolean; +export function matchesScope(scope: Scope): (value: Value) => boolean +export function matchesScope(scope: Scope, value: Value): boolean export function matchesScope(scope: Scope, value?: Value) { return arguments.length == 2 ? matchesScope(scope)(value) : (value: Value) => Object.entries(scope) - .every(([k, v]) => value[k as keyof Scope] === v); + .every(([k, v]) => value[k as keyof Scope] === v) } export function argsAre[]>(args: any[], ...typeChecks: Checks): args is CheckedTypes { return typeChecks.length <= args.length && typeChecks.every((typeCheck, i) => typeCheck(args[i])) - && args.slice(typeChecks.length).every(arg => arg === undefined); + && args.slice(typeChecks.length).every(arg => arg === undefined) } -type TypeCheck = (value: any) => value is T; +type TypeCheck = (value: any) => value is T type CheckedTypes[]> = Checks extends [TypeCheck, ...infer Rest extends TypeCheck[]] ? [First, ...CheckedTypes] - : []; + : [] export function isFunction(value: any): value is Function { - return typeof value == 'function'; -} - -export function isPlainObject(value: T): value is T & object { - return isObject(value) - && Object.getPrototypeOf(value) == Object.prototype; + return typeof value == 'function' } export function isObject(value: T): value is T & object { - return !!value && typeof value == 'object'; + return !!value && typeof value == 'object' } export function isString(value: any): value is string { - return typeof value == 'string'; + return typeof value == 'string' } export function isUndefined(value: any): value is undefined { - return value === undefined; + return value === undefined } -export function getOrAdd(target: any, key: string | symbol, create: (base: T | undefined) => T): T { - return target.hasOwnProperty(key) - ? target[key] - : (target[key] = create(target[key])); +export function getOrAddWeakEntry(cache: WeakMap, key: K, create: (key: K) => V): V { + let cached = cache.get(key) + if (!cached) + cache.set(key, cached = create(key)) + return cached } -export function firstIntersection(a: Set, b: Set) { - for (let item of a) - if (b.has(item)) - return item; +export function getAllPropertyDescriptors(obj: object) { + let props = new Map() + for (let proto of getPrototypeChain(obj)) + for (let [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(proto))) + if (!props.has(key)) + props.set(key, descriptor) + return props } -export function getAllPropertyDescriptors(obj: unknown) { - let props = new Map(); - while (obj != Object.prototype) { - Object.entries(Object.getOwnPropertyDescriptors(obj)) - .filter(([key]) => !props.has(key)) - .forEach(([key, property]) => props.set(key, property)); - obj = Object.getPrototypeOf(obj); +export function* getPrototypeChain(obj: object) { + while (obj != null) { + yield obj + obj = Object.getPrototypeOf(obj) } - return props; } -export function unsubscribeAll(unsubscribes: Unsubscribe[]) { - for (let unsubscribe of unsubscribes) - unsubscribe(); +export function isDisposable(value: any): value is Disposable { + return isObject(value) + && dispose in value } -export const dispose: typeof Symbol.dispose = Symbol.dispose ?? Symbol('Symbol.dispose'); +export const dispose: typeof Symbol.dispose = Symbol.dispose ?? Symbol('Symbol.dispose') -/** Shim for `using` declaration until it's properly supported. */ -export function using(disposable: Resource, action: (resource: Resource) => void) { - try { - action(disposable); - } finally { - disposable[dispose](); - } -} \ No newline at end of file +export const nullDisposable: Disposable = Object.freeze({ [dispose]() { } }) diff --git a/readme.md b/readme.md index dadd726..6bdb9ff 100644 --- a/readme.md +++ b/readme.md @@ -71,7 +71,6 @@ class CounterModel { } } ``` -> **Note:** event-reduce currently relies on `"experimentalDecorators": true` and `"useDefineForClassFields": false` being set in your `tsconfig.json` file. Standard ECMAScript field behaviour and decorator support is coming soon. Then the model can be used like so: ```ts diff --git a/tests-legacy/LegacyModel.test.ts b/tests-legacy/LegacyModel.test.ts index 5bef2e3..2cda2dd 100644 --- a/tests-legacy/LegacyModel.test.ts +++ b/tests-legacy/LegacyModel.test.ts @@ -1,16 +1,15 @@ -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { asyncEvent, derive, derived, event, events, extend, model, reduce, reduced, state } from "event-reduce"; -import { EventsMarkedAsStateError } from "event-reduce/lib/models"; -import { AccessedValueWithCommonSourceError, valueChanged } from "event-reduce/lib/observableValue"; +import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test" +import { asyncEvent, derive, derived, disposeModel, event, events, extendReduce, model, reduce, reduced, state } from "event-reduce" +import { EventsMarkedAsStateError } from "event-reduce/lib/models" describe("legacy models", () => { - let increment: ReturnType>; - let decrement: ReturnType>; + let increment: ReturnType> + let decrement: ReturnType> beforeEach(() => { - increment = event('increment'); - decrement = event('decrement'); - }); + increment = event('increment') + decrement = event('decrement') + }) describe("basic legacy model tests", () => { @model @@ -18,221 +17,235 @@ describe("legacy models", () => { @reduced legacyField = reduce(1) .on(increment, c => c + 1) - .value; + .value @reduced get property() { return reduce(1) .on(increment, c => c + 1) - .value; + .value } @reduced get dependentProperty() { return reduce(1) - .on(valueChanged(this.property), (_, p) => p) - .value; + .on(derive(() => this.property), (_, p) => p) + .value } @derived get derivedProperty() { - return this.property * 2; + return this.property * 2 } @derived - derivedField = derive(() => this.legacyField * 2).value; + derivedField = derive(() => this.legacyField * 2).value @reduced get basedOnDerivedProperty() { return reduce(0) - .on(valueChanged(this.derivedProperty), (_, d) => d) - .value; + .on(derive(() => this.derivedProperty), (_, d) => d) + .value } } @model class ExtendedModel extends TestModel { - override legacyField: number = extend(this.property) + override legacyField: number = extendReduce(this.property) .on(decrement, c => c - 1) - .value; + .value @reduced override get property() { - return extend(super.property) + return extendReduce(super.property) .on(decrement, c => c - 1) - .value; + .value } } - let testModel: TestModel; - let extendedModel: ExtendedModel; + let testModel: TestModel + let extendedModel: ExtendedModel beforeEach(() => { - testModel = new TestModel(); - extendedModel = new ExtendedModel(); - }); + testModel = new TestModel() + extendedModel = new ExtendedModel() + }) - test("property has initial value", () => expect(testModel.property).toBe(1)); + test("property has initial value", () => expect(testModel.property).toBe(1)) - test("extended property has same initial value", () => expect(extendedModel.property).toBe(1)); + test("extended property has same initial value", () => expect(extendedModel.property).toBe(1)) - test("legacy field has initial value", () => expect(testModel.legacyField).toBe(1)); + test("legacy field has initial value", () => expect(testModel.legacyField).toBe(1)) - test("extended legacy field has same initial value", () => expect(extendedModel.legacyField).toBe(1)); + test("extended legacy field has same initial value", () => expect(extendedModel.legacyField).toBe(1)) describe("when reduction updated", () => { beforeEach(() => { - increment(undefined); - }); + increment(undefined) + }) - test("property value updated", () => expect(testModel.property).toBe(2)); + test("property value updated", () => expect(testModel.property).toBe(2)) - test("dependent property value updated", () => expect(testModel.dependentProperty).toBe(2)); + test("dependent property value updated", () => expect(testModel.dependentProperty).toBe(2)) - test("extended property value updated", () => expect(extendedModel.property).toBe(2)); + test("extended property value updated", () => expect(extendedModel.property).toBe(2)) - test("derived property value updated", () => expect(testModel.derivedProperty).toBe(4)); + test("derived property value updated", () => expect(testModel.derivedProperty).toBe(4)) - test("derived field value updated", () => expect(testModel.derivedField).toBe(4)); + test("derived field value updated", () => expect(testModel.derivedField).toBe(4)) - test("property based on derived value updated", () => expect(testModel.basedOnDerivedProperty).toBe(4)); + test("property based on derived value updated", () => expect(testModel.basedOnDerivedProperty).toBe(4)) - test("legacy field value updated", () => expect(testModel.legacyField).toBe(2)); + test("legacy field value updated", () => expect(testModel.legacyField).toBe(2)) - test("extended legacy field value updated", () => expect(extendedModel.legacyField).toBe(2)); - }); + test("extended legacy field value updated", () => expect(extendedModel.legacyField).toBe(2)) + }) describe("when extended reduction updated", () => { beforeEach(() => { - decrement(undefined); - }); + decrement(undefined) + }) - test("property value unaffected", () => expect(testModel.property).toBe(1)); + test("property value unaffected", () => expect(testModel.property).toBe(1)) - test("extended property value updated", () => expect(extendedModel.property).toBe(0)); + test("extended property value updated", () => expect(extendedModel.property).toBe(0)) - test("legacy field value unaffected", () => expect(testModel.legacyField).toBe(1)); + test("legacy field value unaffected", () => expect(testModel.legacyField).toBe(1)) - test("extended legacy field value updated", () => expect(extendedModel.legacyField).toBe(0)); - }); + test("extended legacy field value updated", () => expect(extendedModel.legacyField).toBe(0)) + }) - test("when derivation creates a new model that accesses an observable value in its constructor, accessed observable value is not counted as a source for the derivation", () => { - //@model + test("when derivation creates a new model that accesses an observable value in its constructor, accessed observable value is not counted as an observable for the derivation", () => { + @model class DerivedModel { - property = testModel.property; + property = testModel.property } - let derivation = derive(() => new DerivedModel()); + let derivation = derive(() => new DerivedModel()) + derivation.value - expect(derivation.sources.length).toBe(0); - }); + expect(Array.from(derivation.getSources()).length).toBe(0) + }) test("when reducer creates a new model that observes the same event that created it, doesn't throw", () => { + @model class Parent { @reduced child = reduce(null as TestModel | null) .on(increment, () => new TestModel()) - .value; + .value } - let parentModel = new Parent(); - - increment(undefined); - expect(parentModel.child).toBeInstanceOf(TestModel); - }); + let parentModel = new Parent() - test("when initial value of a model's reduced property is derived from the same event that's creating the model, throws", () => { - reduce(null as ChildModel | null) - .on(increment, () => new ChildModel()); + increment(undefined) + expect(parentModel.child).toBeInstanceOf(TestModel) + }) + test("when reducer creates a nested model on an event, nested model reductions are set up without throwing", () => { + @model class ChildModel { @reduced property = reduce(testModel.property) .on(decrement, c => c - 1) - .value; + .value + } + + @model + class Registry { + @reduced + root = reduce(null as ChildModel | null) + .on(increment, () => new ChildModel()) + .value } - expect(() => increment(undefined)).toThrow(AccessedValueWithCommonSourceError); - }); + let holder = new Registry() + try { + expect(() => increment(undefined)).not.toThrow() + expect(holder.root).toBeInstanceOf(ChildModel) + } finally { + disposeModel(holder) + } + }) test("when event is marked as state, throws when constructed", () => { @model class BadModel { @state - event = event('bad state'); + event = event('bad state') } try { - new BadModel(); - expect.unreachable("Should have thrown"); + new BadModel() + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(EventsMarkedAsStateError); - expect((e as EventsMarkedAsStateError).model).toBeDefined(); - expect((e as EventsMarkedAsStateError).property).toBe('event'); + expect(e).toBeInstanceOf(EventsMarkedAsStateError) + expect((e as EventsMarkedAsStateError).model).toBeDefined() + expect((e as EventsMarkedAsStateError).property).toBe('event') } - }); - }); -}); + }) + }) +}) describe("events decorator", () => { - let getterSpy: Mock<() => void>; + let getterSpy: Mock<() => void> @events class TestEvents { - eventField = asyncEvent(); + eventField = asyncEvent() get eventGetter() { - getterSpy(); - return asyncEvent(); + getterSpy() + return asyncEvent() } } - let sut: TestEvents; + let sut: TestEvents beforeEach(() => { - getterSpy = mock(); - sut = new TestEvents(); - }); + getterSpy = mock() + sut = new TestEvents() + }) - test("keeps class name", () => expect(TestEvents.name).toBe('TestEvents')); + test("keeps class name", () => expect(TestEvents.name).toBe('TestEvents')) test("sets event name", () => { - expect(sut.eventField.displayName).toBe('eventField'); - expect(sut.eventField.started.displayName).toBe('eventField.started'); - expect(sut.eventField.resolved.displayName).toBe('eventField.resolved'); - expect(sut.eventField.rejected.displayName).toBe('eventField.rejected'); - - expect(sut.eventGetter.displayName).toBe('eventGetter'); - expect(sut.eventGetter.started.displayName).toBe('eventGetter.started'); - expect(sut.eventGetter.resolved.displayName).toBe('eventGetter.resolved'); - expect(sut.eventGetter.rejected.displayName).toBe('eventGetter.rejected'); - }); - - test("sets event container", () => { - expect((sut.eventField as any).container).toBe(sut); - expect((sut.eventGetter as any).container).toBe(sut); - }); + expect(sut.eventField.displayName).toBe('eventField') + expect(sut.eventField.started.displayName).toBe('eventField.started') + expect(sut.eventField.resolved.displayName).toBe('eventField.resolved') + expect(sut.eventField.rejected.displayName).toBe('eventField.rejected') + + expect(sut.eventGetter.displayName).toBe('eventGetter') + expect(sut.eventGetter.started.displayName).toBe('eventGetter.started') + expect(sut.eventGetter.resolved.displayName).toBe('eventGetter.resolved') + expect(sut.eventGetter.rejected.displayName).toBe('eventGetter.rejected') + }) + + test("sets event parents", () => { + expect(sut.eventField.path).toBe('TestEvents.eventField') + expect(sut.eventGetter.path).toBe('TestEvents.eventGetter') + }) test("snapshots getter event", () => { - sut.eventGetter; - sut.eventGetter; + sut.eventGetter + sut.eventGetter - expect(getterSpy).toHaveBeenCalledTimes(1); - }); + expect(getterSpy).toHaveBeenCalledTimes(1) + }) test("when event class is marked as state, throws when constructed", () => { @model class BadModel { @state - events = new TestEvents(); + events = new TestEvents() } try { - new BadModel(); - expect.unreachable("Should have thrown"); + new BadModel() + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(EventsMarkedAsStateError); - expect((e as EventsMarkedAsStateError).model).toBeDefined(); - expect((e as EventsMarkedAsStateError).property).toBe('events'); + expect(e).toBeInstanceOf(EventsMarkedAsStateError) + expect((e as EventsMarkedAsStateError).model).toBeDefined() + expect((e as EventsMarkedAsStateError).property).toBe('events') } - }); -}); \ No newline at end of file + }) +}) diff --git a/tests-legacy/LegacyState.test.ts b/tests-legacy/LegacyState.test.ts index ea5685d..59bb4ee 100644 --- a/tests-legacy/LegacyState.test.ts +++ b/tests-legacy/LegacyState.test.ts @@ -1,55 +1,55 @@ -import { describe, test, expect, beforeEach } from "bun:test"; -import { derived, getState, reduce, reduced, setState, state, State } from "event-reduce"; +import { beforeEach, describe, expect, test } from "bun:test" +import { derived, getState, model, reduce, reduced, state } from "event-reduce" describe("legacy state", () => { - + @model class BaseModel { constructor(@state('constructorProperty') public constructorProperty: string) { } @reduced - valueProperty = reduce(1).value; + valueProperty = reduce(1).value @derived get valuePlusOne() { - return this.valueProperty + 1; + return this.valueProperty + 1 } func() { } @reduced - reducedModel = reduce(new ChildModel('child')).value; + reducedModel = reduce(new ChildModel('child')).value @reduced - modelArray = reduce([new ChildModel('one'), new ChildModel('two')]) - .onRestore((_, arr) => arr.map(c => new ChildModel(c.value))) - .value; + modelArray = reduce([new ChildModel('one'), new ChildModel('two')]).value @state - mergedModel = new ChildModel('merged'); + mergedModel = new ChildModel('merged') - ignoredValue = 'ignored'; + ignoredValue = 'ignored' } + @model class TestModel extends BaseModel { @state - subClassProperty = 'subClass'; + subClassProperty = 'subClass' } + @model class ChildModel { constructor(private _initialValue: string) { } @reduced - value = reduce(this._initialValue).value; + value = reduce(this._initialValue).value } - let model: TestModel; + let sut: TestModel beforeEach(() => { - model = new TestModel('ctor'); - }); + sut = new TestModel('ctor') + }) test("getState copies the correct properties", () => { - let result = getState(model); + let result = getState(sut) expect(JSON.stringify(result)).toBe(JSON.stringify({ valueProperty: 1, reducedModel: { value: 'child' }, @@ -57,40 +57,9 @@ describe("legacy state", () => { { value: 'one' }, { value: 'two' } ], + subClassProperty: 'subClass', mergedModel: { value: 'merged' }, - constructorProperty: 'ctor', - subClassProperty: 'subClass' - })); - }); - - test("setState updates properties correctly", () => { - let originalMergedModel = model.mergedModel; - let stateValue = { - valueProperty: 2, - reducedModel: { value: 'child*' }, - modelArray: [ - { value: 'one*' }, - { value: 'two*' }, - { value: 'three*' } - ], - mergedModel: { value: 'merged*' }, - subClassProperty: 'subClass*', - ignoredValue: 'ignored*' - } as State; - setState(model, stateValue); - - expect(model.valueProperty).toBe(2); - expect(model.reducedModel).toBeInstanceOf(ChildModel); - expect(model.reducedModel.value).toBe('child*'); - expect(model.modelArray[0]).toBeInstanceOf(ChildModel); - expect(model.modelArray[1]).toBeInstanceOf(ChildModel); - expect(model.modelArray[2]).toBeInstanceOf(ChildModel); - expect(model.modelArray[0].value).toBe('one*'); - expect(model.modelArray[1].value).toBe('two*'); - expect(model.modelArray[2].value).toBe('three*'); - expect(model.mergedModel).toBe(originalMergedModel); - expect(model.mergedModel.value).toBe('merged*'); - expect(model.subClassProperty).toBe('subClass*'); - expect(model.ignoredValue).toBe('ignored'); - }); -}); \ No newline at end of file + constructorProperty: 'ctor' + })) + }) +}) diff --git a/tests-legacy/package.json b/tests-legacy/package.json index a1fe721..2a00f87 100644 --- a/tests-legacy/package.json +++ b/tests-legacy/package.json @@ -1,19 +1,19 @@ -{ - "name": "event-reduce-tests-legacy", - "version": "1.0.0", - "license": "MIT", - "private": true, - "author": "Simon Oxtoby", - "homepage": "https://github.com/soxtoby/event-reduce", - "repository": { - "type": "git", - "url": "https://github.com/soxtoby/event-reduce.git" - }, - "dependencies": { - "event-reduce": "*", - "event-reduce-react": "*" - }, - "devDependencies": { - "typescript": "~5" - } +{ + "name": "event-reduce-tests-legacy", + "version": "1.0.0", + "license": "MIT", + "private": true, + "author": "Simon Oxtoby", + "homepage": "https://github.com/soxtoby/event-reduce", + "repository": { + "type": "git", + "url": "https://github.com/soxtoby/event-reduce.git" + }, + "dependencies": { + "event-reduce": "*", + "event-reduce-react": "*" + }, + "devDependencies": { + "typescript": "~6.0.3" + } } \ No newline at end of file diff --git a/tests-legacy/tsconfig.json b/tests-legacy/tsconfig.json index 53452bf..f69ff6d 100644 --- a/tests-legacy/tsconfig.json +++ b/tests-legacy/tsconfig.json @@ -1,14 +1,18 @@ -{ - "extends": "../tsconfig.base.json", - "compilerOptions": { - "baseUrl": ".", - "outDir": "./dist", - "rootDir": ".", - "experimentalDecorators": true, - "useDefineForClassFields": false - }, - "include": [ - "./**/*.ts", - "./**/*.tsx" - ] -} \ No newline at end of file +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": ".", + "experimentalDecorators": true, + "useDefineForClassFields": false + }, + "references": [ + { + "path": "../packages/event-reduce" + } + ], + "include": [ + "./**/*.ts", + "./**/*.tsx" + ] +} diff --git a/tests/Cleanup.test.ts b/tests/Cleanup.test.ts index c620102..0eb6c02 100644 --- a/tests/Cleanup.test.ts +++ b/tests/Cleanup.test.ts @@ -1,10 +1,8 @@ -import { beforeEach, describe, expect, spyOn, test, type Mock } from "bun:test"; -import { derive, derived, event, reduce, reduced, state } from "event-reduce"; -import { changeOwnedValue } from "event-reduce/lib/cleanup"; -import { getObservableValue, model } from "event-reduce/lib/models"; -import { ObservableValue } from "event-reduce/lib/observableValue"; -import { StringKey } from "event-reduce/lib/types"; -import { dispose } from "event-reduce/lib/utils"; +import { beforeEach, describe, expect, spyOn, test, type Mock } from "bun:test" +import { createModel, derive, derived, event, ISignal, model, reduce, reduced, signal, state } from "event-reduce" +import { getModelProperties } from "event-reduce/lib/models" +import { isModel } from "event-reduce/lib/symbols" +import { dispose } from "event-reduce/lib/utils" describe("subscription cleanup", () => { @model @@ -12,260 +10,270 @@ describe("subscription cleanup", () => { constructor(private _initial: number) { } @reduced - get reducedProp() { return reduce(this._initial).value }; + get reducedProp() { return reduce(this._initial).value } } test("when reduction returns a new model, disposes old model", () => { - let model = new Model(0); - let unsubscribe = spyOnDispose(model, 'reducedProp'); - let createNewModel = event(); - reduce(model) - .on(createNewModel, () => new Model(1)); + let model = new Model(0) + let unsubscribe = spyOnDispose(model, 'reducedProp') + let createNewModel = event() + using _ = createModel(() => + reduce(model) + .on(createNewModel, () => new Model(1))) - createNewModel(); + createNewModel() - expect(unsubscribe).toHaveBeenCalled(); - }); + expect(unsubscribe).toHaveBeenCalled() + }) test("when reduction returns the same model, doesn't dispose model", () => { - let modelInstance = new Model(0); - let unsubscribe = spyOnDispose(modelInstance, 'reducedProp'); - let returnSameModel = event(); - reduce(modelInstance) - .on(returnSameModel, () => modelInstance); + let modelInstance = new Model(0) + let unsubscribe = spyOnDispose(modelInstance, 'reducedProp') + let returnSameModel = event() + using _ = createModel(() => reduce(modelInstance) + .on(returnSameModel, () => modelInstance)) - returnSameModel(); + returnSameModel() - expect(unsubscribe).not.toHaveBeenCalled(); - }); + expect(unsubscribe).not.toHaveBeenCalled() + }) test("when derivation returns a new model, disposes old model", () => { - let source = new ObservableValue(() => 'test source', 0); - let derivation = derive(() => new Model(source.value)); - let modelInstance = derivation.value; - let unsubscribe = spyOnDispose(modelInstance, 'reducedProp'); + let observable = signal(0, 'test Observable') + let derivation = derive(() => new Model(observable.value)) + let modelInstance = derivation.value + let unsubscribe = spyOnDispose(modelInstance, 'reducedProp') - source.setValue(1); - derivation.value; + observable.setValue(1) + derivation.value - expect(unsubscribe).toHaveBeenCalled(); - }); + expect(unsubscribe).toHaveBeenCalled() + }) test("when derivation returns the same model, doesn't dispose model", () => { - let source = new ObservableValue(() => 'test source', 0); - let modelInstance = new Model(0); + let observable = signal(0, 'test Observable') + let modelInstance = new Model(0) let derivation = derive(() => { - source.value; - return modelInstance; - }); - derivation.value; - let unsubscribe = spyOnDispose(modelInstance, 'reducedProp'); + observable.value + return modelInstance + }) + derivation.value + let unsubscribe = spyOnDispose(modelInstance, 'reducedProp') - source.setValue(1); - derivation.value; + observable.setValue(1) + derivation.value - expect(unsubscribe).not.toHaveBeenCalled(); - }); + expect(unsubscribe).not.toHaveBeenCalled() + }) describe("when derivation returns a model from another observable value", () => { - let model1: ObservableValue; - let model2: ObservableValue; - let useModel2: ObservableValue; - let derivation: ReturnType>; - let modelInstance: Model; - let disposeModelSpy: Mock<() => void>; + let model1: ISignal + let model2: ISignal + let useModel2: ISignal + let derivation: ReturnType> + let modelInstance: Model + let disposeModelSpy: Mock<() => void> beforeEach(() => { - model1 = new ObservableValue(() => 'model 1', new Model(1)); - model2 = new ObservableValue(() => 'model 2', new Model(2)); - useModel2 = new ObservableValue(() => 'switch', false); - derivation = derive(() => useModel2.value ? model2.value : model1.value); - modelInstance = derivation.value; - disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp'); - }); + model1 = signal(new Model(1), 'model 1') + model2 = signal(new Model(2), 'model 2') + useModel2 = signal(false, 'switch') + derivation = derive(() => useModel2.value ? model2.value : model1.value) + modelInstance = derivation.value + disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp') + }) test("when derived model changes to another model, doesn't dispose model", () => { - useModel2.setValue(true); - derivation.value; + useModel2.setValue(true) + derivation.value - expect(disposeModelSpy).not.toHaveBeenCalled(); - }); + expect(disposeModelSpy).not.toHaveBeenCalled() + }) test("when source observable value no longer holds onto model, doesn't dispose model", () => { - model1.setValue(new Model(3)); + model1.setValue(new Model(3)) - expect(disposeModelSpy).not.toHaveBeenCalled(); - }); + expect(disposeModelSpy).not.toHaveBeenCalled() + }) test("when both source observable value and derivation have switched to a different model, model is disposed", () => { - model1.setValue(new Model(3)); - useModel2.setValue(true); - derivation.value; + model1.setValue(new Model(3)) + useModel2.setValue(true) + derivation.value - expect(disposeModelSpy).toHaveBeenCalled(); - }); - }); + expect(disposeModelSpy).toHaveBeenCalled() + }) + }) test("when reduction adds a new model to an array, doesn't dispose model", () => { - let addModel = event(); - let modelInstance = new Model(0); - let disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp'); - reduce([modelInstance]) - .on(addModel, ms => ms.concat(new Model(1))); + let addModel = event() + let modelInstance = new Model(0) + let disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp') + using _ = createModel(() => + reduce([modelInstance]) + .on(addModel, ms => ms.concat(new Model(1)))) - addModel(); + addModel() - expect(disposeModelSpy).not.toHaveBeenCalled(); - }); + expect(disposeModelSpy).not.toHaveBeenCalled() + }) describe("when reduction removes a model from an array", () => { test("when model is only in the reduction, disposes removed model and doesn't dispose remaining model", () => { - let removeModel = event(); - let model1 = new Model(1); - let model2 = new Model(2); - let dispose1 = spyOnDispose(model1, 'reducedProp'); - let dispose2 = spyOnDispose(model2, 'reducedProp'); - reduce([model1, model2]) - .on(removeModel, ms => ms.slice(0, -1)); + let removeModel = event() + let model1 = new Model(1) + let model2 = new Model(2) + let dispose1 = spyOnDispose(model1, 'reducedProp') + let dispose2 = spyOnDispose(model2, 'reducedProp') + using _ = createModel(() => + reduce([model1, model2]) + .on(removeModel, ms => ms.slice(0, -1))) - removeModel(); + removeModel() - expect(dispose2).toHaveBeenCalled(); - expect(dispose1).not.toHaveBeenCalled(); - }); + expect(dispose2).toHaveBeenCalled() + expect(dispose1).not.toHaveBeenCalled() + }) test("when model is also in another observable value, doesn't dispose removed model", () => { - let removeModel = event(); - let model1 = new Model(1); - let model2 = new Model(2); - let dispose1 = spyOnDispose(model1, 'reducedProp'); - let dispose2 = spyOnDispose(model2, 'reducedProp'); - reduce([model1, model2]) - .on(removeModel, ms => ms.slice(0, -1)); + let removeModel = event() + let model1 = new Model(1) + let model2 = new Model(2) + let dispose1 = spyOnDispose(model1, 'reducedProp') + let dispose2 = spyOnDispose(model2, 'reducedProp') + using _ = createModel(() => + reduce([model1, model2]) + .on(removeModel, ms => ms.slice(0, -1))) - new ObservableValue(() => 'other value', model2); + signal(model2, 'other value') - removeModel(); + removeModel() - expect(dispose2).not.toHaveBeenCalled(); - expect(dispose1).not.toHaveBeenCalled(); - }); - }); + expect(dispose2).not.toHaveBeenCalled() + expect(dispose1).not.toHaveBeenCalled() + }) + }) test("when reduction changes an array to a non-array, disposes items in array", () => { - let replaceArray = event(); - let modelInstance = new Model(0); - let disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp'); - reduce([modelInstance] as Model[] | null) - .on(replaceArray, () => null); + let replaceArray = event() + let modelInstance = new Model(0) + let disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp') + using _ = createModel(() => + reduce([modelInstance] as Model[] | null) + .on(replaceArray, () => null)) - replaceArray(); + replaceArray() - expect(disposeModelSpy).toHaveBeenCalled(); - }); + expect(disposeModelSpy).toHaveBeenCalled() + }) test("when reduction adds a model to an object, doesn't dispose model", () => { - let addModel = event(); - let modelInstance = new Model(0); - let disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp'); - reduce({ oldModel: modelInstance }) - .on(addModel, ms => ({ ...ms, newModel: new Model(1) })); + let addModel = event() + let modelInstance = new Model(0) + let disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp') + using _ = createModel(() => + reduce({ oldModel: modelInstance }) + .on(addModel, ms => ({ ...ms, newModel: new Model(1) }))) - addModel(); + addModel() - expect(disposeModelSpy).not.toHaveBeenCalled(); - }); + expect(disposeModelSpy).not.toHaveBeenCalled() + }) describe("when reduction removes a model from an object", () => { test("when model is only in the reduction, disposes removed model and doesn't dispose remaining model", () => { - let removeModel = event(); - let model1 = new Model(1); - let model2 = new Model(2); - let dispose1 = spyOnDispose(model1, 'reducedProp'); - let dispose2 = spyOnDispose(model2, 'reducedProp'); - reduce({ model1, model2 } as Record) - .on(removeModel, ms => { - let { model2, ...remaining } = ms; - return remaining; - }); - - removeModel(); - - expect(dispose2).toHaveBeenCalled(); - expect(dispose1).not.toHaveBeenCalled(); - }); + let removeModel = event() + let model1 = new Model(1) + let model2 = new Model(2) + let dispose1 = spyOnDispose(model1, 'reducedProp') + let dispose2 = spyOnDispose(model2, 'reducedProp') + using _ = createModel(() => + reduce({ model1, model2 } as Record) + .on(removeModel, ms => { + let { model2, ...remaining } = ms + return remaining + })) + + removeModel() + + expect(dispose2).toHaveBeenCalled() + expect(dispose1).not.toHaveBeenCalled() + }) test("when model is also in another observable value, doesn't dispose models", () => { - let removeModel = event(); - let model1 = new Model(1); - let model2 = new Model(2); - let dispose1 = spyOnDispose(model1, 'reducedProp'); - let dispose2 = spyOnDispose(model2, 'reducedProp'); - reduce({ model1, model2 } as Record) - .on(removeModel, ms => { - let { model2, ...remaining } = ms; - return remaining; - }); - - new ObservableValue(() => 'other value', model2); - - removeModel(); - - expect(dispose2).not.toHaveBeenCalled(); - expect(dispose1).not.toHaveBeenCalled(); - }); - }); + let removeModel = event() + let model1 = new Model(1) + let model2 = new Model(2) + let dispose1 = spyOnDispose(model1, 'reducedProp') + let dispose2 = spyOnDispose(model2, 'reducedProp') + using _ = createModel(() => + reduce({ model1, model2 } as Record) + .on(removeModel, ms => { + let { model2, ...remaining } = ms + return remaining + })) + + signal(model2, 'other value') + + removeModel() + + expect(dispose2).not.toHaveBeenCalled() + expect(dispose1).not.toHaveBeenCalled() + }) + }) test("when reduction replaces a model on an object, disposes removed model and doesn't dispose remaining model", () => { - let removeModel = event(); - let model1 = new Model(1); - let model2 = new Model(2); - let dispose1 = spyOnDispose(model1, 'reducedProp'); - let dispose2 = spyOnDispose(model2, 'reducedProp'); - reduce({ model1, model2 } as Record) - .on(removeModel, ms => ({ ...ms, model2: new Model(3) })); + let removeModel = event() + let model1 = new Model(1) + let model2 = new Model(2) + let dispose1 = spyOnDispose(model1, 'reducedProp') + let dispose2 = spyOnDispose(model2, 'reducedProp') + using _ = createModel(() => + reduce({ model1, model2 } as Record) + .on(removeModel, ms => ({ ...ms, model2: new Model(3) }))) - removeModel(); + removeModel() - expect(dispose2).toHaveBeenCalled(); - expect(dispose1).not.toHaveBeenCalled(); - }); + expect(dispose2).toHaveBeenCalled() + expect(dispose1).not.toHaveBeenCalled() + }) test("when reduction changes an object to a non-object, disposes values of object", () => { - let replaceObject = event(); - let modelInstance = new Model(0); - let disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp'); - reduce({ model: modelInstance } as Record | null) - .on(replaceObject, () => null); + let replaceObject = event() + let modelInstance = new Model(0) + let disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp') + using _ = createModel(() => + reduce({ model: modelInstance } as Record | null) + .on(replaceObject, () => null)) - replaceObject(); + replaceObject() - expect(disposeModelSpy).toHaveBeenCalled(); - }); + expect(disposeModelSpy).toHaveBeenCalled() + }) describe("when array items are also in another observable value", () => { test("when array value is disposed, doesn't dispose items", () => { - let arrayValue = new ObservableValue(() => 'array value', [new Model(0)]); - new ObservableValue(() => 'other value', arrayValue.value[0]); - let disposeModelSpy = spyOnDispose(arrayValue.value[0], 'reducedProp'); + let arrayValue = signal([new Model(0)], 'array value') + signal(arrayValue.value[0], 'other value') + let disposeModelSpy = spyOnDispose(arrayValue.value[0], 'reducedProp') - arrayValue[dispose](); + arrayValue[dispose]() - expect(disposeModelSpy).not.toHaveBeenCalled(); - }); - }); + expect(disposeModelSpy).not.toHaveBeenCalled() + }) + }) describe("when object values are also in another observable value", () => { test("when object value is disposed, doesn't dispose values", () => { - let objectValue = new ObservableValue(() => 'object value', { model: new Model(0) }); - new ObservableValue(() => 'other value', objectValue.value.model); - let disposeModelSpy = spyOnDispose(objectValue.value.model, 'reducedProp'); + let objectValue = signal({ model: new Model(0) }, 'object value') + signal(objectValue.value.model, 'other value') + let disposeModelSpy = spyOnDispose(objectValue.value.model, 'reducedProp') - objectValue[dispose](); + objectValue[dispose]() - expect(disposeModelSpy).not.toHaveBeenCalled(); - }); - }); + expect(disposeModelSpy).not.toHaveBeenCalled() + }) + }) test("when model disposed, disposes all properties correctly", () => { @model @@ -273,42 +281,47 @@ describe("subscription cleanup", () => { constructor(private _ownedModel: Model) { } @reduced - get reducedProp() { return reduce(new Model(0)).value; } + get reducedProp() { return reduce(new Model(0)).value } @derived - get derivedProp() { return new Model(1); } + get derivedProp() { return new Model(1) } @state - stateProp = new Model(2); + stateProp = new Model(2) @derived - get externallyOwned() { return this._ownedModel; } + get externallyOwned() { return this._ownedModel } + } + let externalOwner = signal(new Model(3), 'external owner') + let outerModel = new OuterModel(externalOwner.value) as OuterModel & Disposable + let disposeReducedPropValue = spyOnDispose(outerModel.reducedProp, 'reducedProp') + let disposeDerivedPropValue = spyOnDispose(outerModel.derivedProp, 'reducedProp') + let disposeStatePropValue = spyOnDispose(outerModel.stateProp, 'reducedProp') + let disposeExternallyOwnedPropValue = spyOnDispose(outerModel.externallyOwned, 'reducedProp') + let disposeReducedProp = spyOnDispose(outerModel, 'reducedProp') + let disposeDerivedProp = spyOnDispose(outerModel, 'derivedProp') + let disposeExternallyOwnedProp = spyOnDispose(outerModel, 'externallyOwned') + + let outerOwner = signal(outerModel, 'outer owner') + outerOwner.setValue(null!) + + expect(disposeReducedProp).toHaveBeenCalled() + expect(disposeDerivedProp).toHaveBeenCalled() + expect(disposeExternallyOwnedProp).toHaveBeenCalled() + + expect(disposeReducedPropValue).toHaveBeenCalled() + expect(disposeDerivedPropValue).toHaveBeenCalled() + expect(disposeStatePropValue).toHaveBeenCalled() + + expect(disposeExternallyOwnedPropValue).not.toHaveBeenCalled() + }) + + function spyOnDispose(model: T, property: keyof T) { + if (isModel(model)) { + let observableValue = getModelProperties(model)[property]! + if (dispose in observableValue) + return spyOn(observableValue, dispose) } - let externalOwner = new ObservableValue(() => 'external owner', new Model(3)); - let outerModel = new OuterModel(externalOwner.value); - let disposeReducedPropValue = spyOnDispose(outerModel.reducedProp, 'reducedProp'); - let disposeDerivedPropValue = spyOnDispose(outerModel.derivedProp, 'reducedProp'); - let disposeStatePropValue = spyOnDispose(outerModel.stateProp, 'reducedProp'); - let disposeExternallyOwnedPropValue = spyOnDispose(outerModel.externallyOwned, 'reducedProp'); - let disposeReducedProp = spyOnDispose(outerModel, 'reducedProp'); - let disposeDerivedProp = spyOnDispose(outerModel, 'derivedProp'); - let disposeExternallyOwnedProp = spyOnDispose(outerModel, 'externallyOwned'); - - changeOwnedValue(undefined!, outerModel, undefined); - - expect(disposeReducedProp).toHaveBeenCalled(); - expect(disposeDerivedProp).toHaveBeenCalled(); - expect(disposeExternallyOwnedProp).toHaveBeenCalled(); - - expect(disposeReducedPropValue).toHaveBeenCalled(); - expect(disposeDerivedPropValue).toHaveBeenCalled(); - expect(disposeStatePropValue).toHaveBeenCalled(); - - expect(disposeExternallyOwnedPropValue).not.toHaveBeenCalled(); - }); - - function spyOnDispose(model: T, property: StringKey) { - let observableValue = getObservableValue(model, property)!; - return spyOn(observableValue, dispose); + throw new Error(`Property ${String(property)} is not a disposable observable value on the provided model`) } -}); +}) diff --git a/tests/Derivation.test.ts b/tests/Derivation.test.ts index ea4dc22..7c10514 100644 --- a/tests/Derivation.test.ts +++ b/tests/Derivation.test.ts @@ -1,137 +1,138 @@ -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { derive, event, events } from "event-reduce"; -import { DerivedEventsError, SideEffectInDerivationError } from "event-reduce/lib/derivation"; -import { consumeLastAccessed, ObservableValue } from "event-reduce/lib/observableValue"; +import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test" +import { derive, event, events, signal, type IDerivation, type ISignal } from "event-reduce" +import { InvalidStateError } from "event-reduce/lib/observable" +import { getUnderlyingObservable, SideEffectDuringReactionError } from "event-reduce/lib/tracking" +import { MockObserver } from "./MockObserver" describe("derive", () => { - let sourceA: ObservableValue; - let sourceB: ObservableValue; - let calculation: Mock<() => string>; - let sut: ReturnType>; + let sourceA: ISignal + let sourceB: ISignal + let calculation: Mock<() => string> + let sut: IDerivation beforeEach(() => { - sourceA = new ObservableValue(() => 'a', 'a'); - sourceB = new ObservableValue(() => 'b', 'b'); - calculation = mock(() => sourceA.value + sourceB.value); - sut = derive(calculation, 'sut'); - }); + sourceA = signal('a', 'a') + sourceB = signal('b', 'b') + calculation = mock(() => sourceA.value + sourceB.value) + sut = derive(calculation, 'sut') + }) - test("has provided name", () => expect(sut.displayName).toBe('sut')); + test("has provided name", () => expect(sut.displayName).toBe('sut')) describe("when value accessed", () => { - let result: string; + let result: string beforeEach(() => { - result = sut.value; - }); + result = sut.value + }) - test("returns result of calculation", () => expect(result).toBe('ab')); + test("returns result of calculation", () => expect(result).toBe('ab')) - test("derivation is last accessed value", () => expect(consumeLastAccessed()!).toBe(sut)); + test("derivation is last accessed value", () => expect(getUnderlyingObservable(result)!).toBe(sut)) test("when accessed again, returns same value and doesn't re-compute the value", () => { - let result2 = sut.value; + let result2 = sut.value - expect(result2).toBe(result); - expect(calculation).toHaveBeenCalledTimes(1); - }); + expect(result2).toBe(result) + expect(calculation).toHaveBeenCalledTimes(1) + }) - test("when a source value changed then accessed again, returns updated value", () => { - sourceB.setValue('B'); + test("when an observable value changed then accessed again, returns updated value", () => { + sourceB.setValue('B') - let result2 = sut.value; + let result2 = sut.value - expect(result2).toBe('aB'); - }); + expect(result2).toBe('aB') + }) - test("when multiple source values changed then accessed again, returns updated value and re-computes only once", () => { - calculation.mockClear(); - sourceA.setValue('A'); - sourceB.setValue('B'); + test("when multiple observable values changed then accessed again, returns updated value and re-computes only once", () => { + calculation.mockClear() + sourceA.setValue('A') + sourceB.setValue('B') - let result2 = sut.value; + let result2 = sut.value - expect(result2).toBe('AB'); - expect(calculation).toHaveBeenCalledTimes(1); - }); + expect(result2).toBe('AB') + expect(calculation).toHaveBeenCalledTimes(1) + }) describe("when subscribed to", () => { - let observe: Mock<(value: string) => void>; + let observer: MockObserver beforeEach(() => { - observe = mock(); - sut.subscribe(observe); - }); + observer = new MockObserver() + sut.subscribe(observer) + }) - test("doesn't notify immediately", () => expect(observe).not.toHaveBeenCalled()); + test("doesn't notify immediately", () => expect(observer.sourceUpdated).not.toHaveBeenCalled()) - test("when a source value changed, notifies observer", () => { - sourceA.setValue('A'); + test("when an observable value changed, notifies observer", () => { + sourceA.setValue('A') - expect(observe).toHaveBeenCalled(); - }); - }); - }); + expect(observer.sourceUpdated).toHaveBeenCalled() + }) + }) + }) test("when derivation fires an event, throws", () => { - let sideEffect = event('some event'); - let derivation = derive(() => sideEffect()); + let sideEffect = event('some event') + let derivation = derive(() => sideEffect()) try { - derivation.value; - expect.unreachable("Should have thrown"); + derivation.value + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(SideEffectInDerivationError); - expect((e as SideEffectInDerivationError).derivation).toBe(derivation); - expect((e as SideEffectInDerivationError).sideEffect).toBe('some event'); + expect(e).toBeInstanceOf(SideEffectDuringReactionError) + expect((e as SideEffectDuringReactionError).reactingTo).toBe(derivation) + expect((e as SideEffectDuringReactionError).sideEffect).toBe(sideEffect) } - }); + }) test("when derivation returns an event, accessing value throws", () => { - let eventValue = event('derived event'); - let derivation = derive(() => sourceA.value && eventValue); + let eventValue = event('derived event') + let derivation = derive(() => sourceA.value && eventValue) try { - derivation.value; - expect.unreachable("Should have thrown"); + derivation.value + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(DerivedEventsError); - expect((e as DerivedEventsError).derivation).toBe(derivation); - expect((e as DerivedEventsError).value).toBe(eventValue); + expect(e).toBeInstanceOf(InvalidStateError) + expect((e as InvalidStateError).observable).toBe(derivation) + expect((e as InvalidStateError).value).toBe(eventValue) } - }); + }) test("when derivation returns an events class, accessing value throws", () => { @events class Events { } - let eventsValue = new Events(); - let derivation = derive(() => sourceA.value && eventsValue); + let eventsValue = new Events() + let derivation = derive(() => sourceA.value && eventsValue) try { - derivation.value; - expect.unreachable("Should have thrown"); + derivation.value + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(DerivedEventsError); - expect((e as DerivedEventsError).derivation).toBe(derivation); - expect((e as DerivedEventsError).value).toBe(eventsValue); + expect(e).toBeInstanceOf(InvalidStateError) + expect((e as InvalidStateError).observable).toBe(derivation) + expect((e as InvalidStateError).value).toBe(eventsValue) } - }); -}); + }) +}) test("derivation that depends on other derivations only updates once", () => { - let source = new ObservableValue(() => "source", "a"); - let inner1 = derive(() => source.value + "_inner1"); - let inner2 = derive(() => source.value + "_inner2"); - let outerCalc = mock(() => inner1.value + inner2.value); - let outer = derive(outerCalc); - outer.subscribe(() => { }); // Outer must be observed for sources to trigger an update + let observable = signal("a", "Observable") + let inner1 = derive(() => observable.value + "_inner1") + let inner2 = derive(() => observable.value + "_inner2") + let outerCalc = mock(() => inner1.value + inner2.value) + let outer = derive(outerCalc) + outer.subscribe(new MockObserver()) // Outer must be observed for sources to trigger an update // Initial access to set up dependencies - outer.value; - expect(outerCalc).toHaveBeenCalledTimes(1); + outer.value + expect(outerCalc).toHaveBeenCalledTimes(1) // This should trigger updates to inner1 and inner2, but outer should only re-calculate once - source.setValue("b"); - outer.value; - expect(outerCalc).toHaveBeenCalledTimes(2); -}); \ No newline at end of file + observable.setValue("b") + outer.value + expect(outerCalc).toHaveBeenCalledTimes(2) +}) diff --git a/tests/Event.test.ts b/tests/Event.test.ts index 2518df1..628f835 100644 --- a/tests/Event.test.ts +++ b/tests/Event.test.ts @@ -1,129 +1,317 @@ -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart } from 'event-reduce'; -import { ChainedEventsError } from "event-reduce/lib/events"; - -describe("event", () => { - type TestType = { foo: string, bar: number }; - let sut: ReturnType>; - - beforeEach(() => { - sut = event('sut'); - }); - - describe("when subscribed to", () => { - let subscriber: Mock<(value: TestType) => void>; - - beforeEach(() => { - subscriber = mock(); - sut.subscribe(subscriber); - }); - - test("when called, passes value to subscribers", () => { - let value = { foo: 'foo', bar: 1 }; - - sut(value); - - expect(subscriber).toHaveBeenCalledWith(value); - }); - }); - - test("when handler calls another event, throws", () => { - let otherEvent = event('otherEvent'); - sut.subscribe(() => otherEvent(undefined)); - - try { - sut({ foo: 'foo', bar: 1 }); - expect.unreachable("Should have thrown"); - } catch (e) { - expect(e).toBeInstanceOf(ChainedEventsError); - expect((e as ChainedEventsError).currentEvent).toBe(sut.displayName); - expect((e as ChainedEventsError).newEvent).toBe(otherEvent.displayName); - } - }); - - describe("scope", () => { - let scoped: any; - let rootSubscriber: Mock<(value: TestType) => void>; - let scopedSubscriber: Mock<(value: TestType) => void>; - - beforeEach(() => { - scoped = sut.scope({ foo: 'foo' }); - rootSubscriber = mock(); - scopedSubscriber = mock(); - sut.subscribe(rootSubscriber); - scoped.subscribe(scopedSubscriber); - }); - - test("when non-matching value passed to parent event, scoped event does not pass on value", () => { - sut({ foo: 'bar', bar: 1 }); - - expect(scopedSubscriber).not.toHaveBeenCalled(); - }); - - test("when matching value passed to parent event, scoped event passed on value", () => { - let value = { foo: 'foo', bar: 1 }; - - sut(value); - - expect(scopedSubscriber).toHaveBeenCalledWith(value); - }); - - test("when called with partial value, scoped event fills in scope values", () => { - scoped({ bar: 2 }); - - expect(rootSubscriber).toHaveBeenCalledWith(expect.objectContaining({ foo: 'foo', bar: 2 })); - expect(scopedSubscriber).toHaveBeenCalledWith(expect.objectContaining({ foo: 'foo', bar: 2 })); - }); - }); -}); - -describe("asyncEvent", () => { - type Result = { foo: string; }; - type Context = { bar: string; }; - let sut: ReturnType>; - - let started: Mock<(value: AsyncStart) => void>; - let resolved: Mock<(value: AsyncResult) => void>; - let rejected: Mock<(value: AsyncError) => void>; - - beforeEach(() => { - sut = asyncEvent(); - started = mock(); - resolved = mock(); - rejected = mock(); - sut.started.subscribe(started); - sut.resolved.subscribe(resolved); - sut.rejected.subscribe(rejected); - }); - - describe("when called", () => { - let promise: Promise; - let resolve: (value: Result) => void; - let reject: (error: any) => void; - let context: Context; - - beforeEach(() => { - ({ promise, resolve, reject } = Promise.withResolvers()); - context = { bar: 'context' }; - sut(promise, context); - }); - - test("started event fired", () => expect(started).toHaveBeenCalledWith(expect.objectContaining({ promise, context }))); - - test("when promise resolved, resolved event fired", async () => { - let result = { foo: 'result' }; - resolve(result); - await promise; - - expect(resolved).toHaveBeenCalledWith(expect.objectContaining({ result, context })); - }); - - test("when promise rejected, rejected event fired", async () => { - let error = { message: 'error' }; - reject(error); - await promise.catch(() => { }); // Catch to prevent unhandled rejection - - expect(rejected).toHaveBeenCalledWith(expect.objectContaining({ error, context })); - }); - }); -}); \ No newline at end of file +import { beforeEach, describe, expect, test } from "bun:test" +import { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type IAsyncObservable } from 'event-reduce' +import { makePhasedEvent, type IPhasedObservable } from "event-reduce/lib/events" +import { SideEffectDuringReactionError } from "event-reduce/lib/tracking" +import { MockObserver } from "./MockObserver" + +describe("event", () => { + type TestType = { foo: string, bar: number } + let sut: ReturnType> + + beforeEach(() => { + sut = event('sut') + }) + + describe("when subscribed to", () => { + let subscriber: MockObserver + + beforeEach(() => { + subscriber = new MockObserver() + sut.subscribe(subscriber) + }) + + test("when called, passes value to subscribers", () => { + let value = { foo: 'foo', bar: 1 } + + sut(value) + + expect(subscriber.sourceUpdated).toHaveBeenCalledWith(sut, value) + }) + }) + + test("when handler calls another event, throws", () => { + let otherEvent = event('otherEvent') + let observer = new MockObserver() + observer.sourceUpdated = () => otherEvent(undefined) + sut.subscribe(observer) + + try { + sut({ foo: 'foo', bar: 1 }) + expect.unreachable("Should have thrown") + } catch (e) { + expect(e).toBeInstanceOf(SideEffectDuringReactionError) + expect((e as SideEffectDuringReactionError).reactingTo).toBe(sut) + expect((e as SideEffectDuringReactionError).sideEffect).toBe(otherEvent) + } + }) + + describe("scope", () => { + let scoped: any + let rootObserver: MockObserver + let scopedObserver: MockObserver + + beforeEach(() => { + scoped = sut.scope({ foo: 'foo' }) + rootObserver = new MockObserver() + scopedObserver = new MockObserver() + sut.subscribe(rootObserver) + scoped.subscribe(scopedObserver) + }) + + test("when non-matching value passed to parent event, scoped event does not pass on value", () => { + sut({ foo: 'bar', bar: 1 }) + + expect(scopedObserver.sourceUpdated).not.toHaveBeenCalled() + }) + + test("when matching value passed to parent event, scoped event passed on value", () => { + let value = { foo: 'foo', bar: 1 } + + sut(value) + + expect(scopedObserver.sourceUpdated).toHaveBeenCalledWith(scoped, value) + }) + + test("when called with partial value, scoped event fills in scope values", () => { + scoped({ bar: 2 }) + + expect(rootObserver.sourceUpdated).toHaveBeenCalledWith(sut, expect.objectContaining({ foo: 'foo', bar: 2 })) + expect(scopedObserver.sourceUpdated).toHaveBeenCalledWith(scoped, expect.objectContaining({ foo: 'foo', bar: 2 })) + }) + }) +}) + +describe("asyncEvent", () => { + type Result = { foo: string } + type Context = { bar: string } + let sut: ReturnType> + + let started: MockObserver> + let resolved: MockObserver> + let rejected: MockObserver> + + beforeEach(() => { + sut = asyncEvent() + started = new MockObserver() + resolved = new MockObserver() + rejected = new MockObserver() + sut.started.subscribe(started) + sut.resolved.subscribe(resolved) + sut.rejected.subscribe(rejected) + }) + + describe("when called", () => { + let promise: Promise + let resolve: (value: Result) => void + let reject: (error: any) => void + let context: Context + + beforeEach(() => { + ({ promise, resolve, reject } = Promise.withResolvers()) + context = { bar: 'context' } + sut(promise, context) + }) + + test("started event fired", () => expect(started.sourceUpdated).toHaveBeenCalledWith(sut.started, expect.objectContaining({ phase: 'started', promise, context }))) + + test("when promise resolved, resolved event fired", async () => { + let result = { foo: 'result' } + resolve(result) + await promise + + expect(resolved.sourceUpdated).toHaveBeenCalledWith(sut.resolved, expect.objectContaining({ phase: 'resolved', result, context })) + }) + + test("when promise rejected, rejected event fired", async () => { + let error = { message: 'error' } + reject(error) + await promise.catch(() => { }) // Catch to prevent unhandled rejection + + expect(rejected.sourceUpdated).toHaveBeenCalledWith(sut.rejected, expect.objectContaining({ phase: 'rejected', error, context })) + }) + }) + + describe("filterContext", () => { + let filtered: IAsyncObservable + let filteredStarted: MockObserver> + let filteredResolved: MockObserver> + let filteredRejected: MockObserver> + + beforeEach(() => { + filtered = sut.filterContext(context => context.bar == 'match') + filteredStarted = new MockObserver() + filteredResolved = new MockObserver() + filteredRejected = new MockObserver() + filtered.started.subscribe(filteredStarted) + filtered.resolved.subscribe(filteredResolved) + filtered.rejected.subscribe(filteredRejected) + }) + + test("when context matches, filters all async event phases through", async () => { + let resolved = Promise.resolve({ foo: 'result' }) + let rejected = Promise.reject({ message: 'error' }) + rejected.catch(() => { }) // Catch to prevent unhandled rejection + + sut(resolved, { bar: 'match' }) + sut(rejected, { bar: 'match' }) + await resolved + await rejected.catch(() => { }) + + expect(filteredStarted.sourceUpdated).toHaveBeenCalledWith(filtered.started, expect.objectContaining({ phase: 'started', context: { bar: 'match' } })) + expect(filteredResolved.sourceUpdated).toHaveBeenCalledWith(filtered.resolved, expect.objectContaining({ phase: 'resolved', result: { foo: 'result' }, context: { bar: 'match' } })) + expect(filteredRejected.sourceUpdated).toHaveBeenCalledWith(filtered.rejected, expect.objectContaining({ phase: 'rejected', error: { message: 'error' }, context: { bar: 'match' } })) + }) + + test("when context does not match, filters all async event phases out", async () => { + let resolved = Promise.resolve({ foo: 'result' }) + let rejected = Promise.reject({ message: 'error' }) + rejected.catch(() => { }) // Catch to prevent unhandled rejection + + sut(resolved, { bar: 'other' }) + sut(rejected, { bar: 'other' }) + await resolved + await rejected.catch(() => { }) + + expect(filteredStarted.sourceUpdated).not.toHaveBeenCalled() + expect(filteredResolved.sourceUpdated).not.toHaveBeenCalled() + expect(filteredRejected.sourceUpdated).not.toHaveBeenCalled() + }) + + test("can chain context filters", async () => { + let chained = sut + .filterContext(context => context.bar.length > 1) + .filterContext(context => context.bar == 'match') + let observed = new MockObserver>() + chained.resolved.subscribe(observed) + + let resolved = Promise.resolve({ foo: 'result' }) + sut(resolved, { bar: 'match' }) + await resolved + + expect(observed.sourceUpdated).toHaveBeenCalledWith(chained.resolved, expect.objectContaining({ phase: 'resolved', result: { foo: 'result' }, context: { bar: 'match' } })) + }) + + test("sets display names for filtered async observables and their phases", () => { + let filtered = sut.filterContext(context => context.bar == 'match', 'matching') + + expect(filtered.displayName).toBe('matching') + expect(filtered.started.displayName).toBe('matching.started') + expect(filtered.resolved.displayName).toBe('matching.resolved') + expect(filtered.rejected.displayName).toBe('matching.rejected') + }) + + test("supports context type guards", () => { + type MixedContext = + | { kind: 'matching'; bar: string } + | { kind: 'other'; baz: string } + + let mixed = asyncEvent() + let matching = mixed.filterContext((context): context is Extract => context.kind == 'matching') + let expected: IAsyncObservable = matching + + expected.resolved.subscribe(new MockObserver>()) + }) + }) +}) + +describe("makePhasedEvent", () => { + type Context = { id: string, group: string } + type Value = + | { phase: 'started', input: string, context: Context } + | { phase: 'next', value: string, context: Context } + | { phase: 'completed', context: Context } + + function testPhasedEvent(name = 'test phased') { + return makePhasedEvent({ + name, + phases: ['started', 'next', 'completed'], + fire(emit: (value: Value) => void, input: string, context: Context) { + emit({ phase: 'started', input, context }) + return () => { + emit({ phase: 'next', value: input.toUpperCase(), context }) + emit({ phase: 'completed', context }) + } + } + }) + } + + test("adds phase discriminator and exposes phase observables", () => { + let sut = testPhasedEvent() + let all = new MockObserver() + let started = new MockObserver() + let next = new MockObserver() + sut.subscribe(all) + sut.started.subscribe(started) + sut.next.subscribe(next) + + let flush: () => void = sut('value', { id: '1', group: 'a' }) + flush() + + expect(started.sourceUpdated).toHaveBeenCalledWith(sut.started, expect.objectContaining({ phase: 'started', input: 'value' })) + expect(next.sourceUpdated).toHaveBeenCalledWith(sut.next, expect.objectContaining({ phase: 'next', value: 'VALUE' })) + expect(all.sourceUpdated).toHaveBeenCalledWith(sut, expect.objectContaining({ phase: 'completed', context: { id: '1', group: 'a' } })) + }) + + test("filters all phases by context", () => { + let sut = testPhasedEvent() + let filtered = sut.filterContext(context => context.group == 'a') + let next = new MockObserver() + filtered.next.subscribe(next) + + sut('skip', { id: '1', group: 'b' })() + sut('keep', { id: '2', group: 'a' })() + + expect(next.sourceUpdated).toHaveBeenCalledTimes(1) + expect(next.sourceUpdated).toHaveBeenCalledWith(filtered.next, expect.objectContaining({ phase: 'next', value: 'KEEP', context: { id: '2', group: 'a' } })) + }) + + test("scopes context by merging into last argument", () => { + let sut = testPhasedEvent() + let scoped = sut.scope({ id: 'scoped' }) + let expectedScopedSource: IPhasedObservable = scoped + let root = new MockObserver() + let scopedNext = new MockObserver() + sut.subscribe(root) + expectedScopedSource.next.subscribe(scopedNext) + + scoped('value', { group: 'a' }) + + expect(root.sourceUpdated).toHaveBeenCalledWith(sut, expect.objectContaining({ phase: 'started', context: { id: 'scoped', group: 'a' } })) + expect(scopedNext.sourceUpdated).not.toHaveBeenCalled() + + sut('other', { id: 'scoped', group: 'a' })() + + expect(scopedNext.sourceUpdated).toHaveBeenCalledWith(scoped.next, expect.objectContaining({ phase: 'next', context: { id: 'scoped', group: 'a' } })) + }) + + test("sets display names for phases", () => { + let sut = testPhasedEvent('custom') + + expect(sut.started.displayName).toBe('custom.started') + expect(sut.next.displayName).toBe('custom.next') + expect(sut.completed.displayName).toBe('custom.completed') + + sut.setDisplayName('renamed') + + expect(sut.started.displayName).toBe('renamed.started') + expect(sut.next.displayName).toBe('renamed.next') + expect(sut.completed.displayName).toBe('renamed.completed') + }) + + test("supports context type guards", () => { + type MixedContext = + | { kind: 'matching'; id: string } + | { kind: 'other'; id: string } + type MixedValue = { phase: 'next', value: string, context: MixedContext } + + let sut = makePhasedEvent({ + name: 'mixed', + phases: ['next'], + fire: (emit: (value: MixedValue) => void, value: string, context: MixedContext) => emit({ phase: 'next', value, context }) + }) + let matching = sut.filterContext((context): context is Extract => context.kind == 'matching') + let expected: IPhasedObservable<{ phase: 'next', value: string, context: { kind: 'matching'; id: string } }, { kind: 'matching'; id: string }> = matching + + expected.next.subscribe(new MockObserver()) + }) +}) diff --git a/tests/Example.test.tsx b/tests/Example.test.tsx index 50fa286..f67ddb2 100644 --- a/tests/Example.test.tsx +++ b/tests/Example.test.tsx @@ -1,180 +1,184 @@ -import { fireEvent, render, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, test } from "bun:test"; -import { Counter, CounterEvents, CounterModel } from "event-reduce-example/Counter"; -import { CounterList, CounterListEvents, CounterListModel } from "event-reduce-example/CounterList"; -import { eventProxy, mutable } from "event-reduce/lib/testing"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { Counter, CounterEvents, CounterModel } from "event-reduce-example/Counter" +import { CounterList, CounterListEvents, CounterListModel } from "event-reduce-example/CounterList" +import { eventProxy, Mutable, mutable } from "event-reduce/lib/testing" describe("CounterListModel", () => { - let events: CounterListEvents; - let sut: CounterListModel; + let events: CounterListEvents + let sut: CounterListModel beforeEach(() => { - events = new CounterListEvents(); - sut = new CounterListModel(events); - }); + events = new CounterListEvents() + sut = new CounterListModel(events) + }) - test("there are no counters by default", () => expect(sut.counters.length).toBe(0)); + test("there are no counters by default", () => expect(sut.counters.length).toBe(0)) describe("when counter added", () => { - let firstCounter: CounterModel; + let firstCounter: CounterModel beforeEach(() => { - events.counterAdded(); - firstCounter = sut.counters[0]; - }); + events.counterAdded() + firstCounter = sut.counters[0] + }) - test("has a counter", () => expect(sut.counters.length).toBe(1)); + test("has a counter", () => expect(sut.counters.length).toBe(1)) describe("when another counter added", () => { beforeEach(() => { - events.counterAdded(); - }); + events.counterAdded() + }) - test("keeps first counter", () => expect(sut.counters[0]).toBe(firstCounter)); + test("keeps first counter", () => expect(sut.counters[0]).toBe(firstCounter)) - test("has two counters", () => expect(sut.counters.length).toBe(2)); + test("has two counters", () => expect(sut.counters.length).toBe(2)) test("when second counter removed, only has first counter left", () => { - events.counterRemoved({ id: sut.counters[1].id }); + events.counterRemoved({ id: sut.counters[1].id }) - expect(sut.counters.length).toBe(1); - expect(sut.counters[0]).toBe(firstCounter); - }); - }); - }); -}); + expect(sut.counters.length).toBe(1) + expect(sut.counters[0]).toBe(firstCounter) + }) + }) + }) +}) describe("CounterList", () => { - let model: CounterListModel; - let sut: ReturnType; + let model: CounterListModel + let sut: ReturnType beforeEach(() => { - model = new CounterListModel(new CounterListEvents()); - sut = render(); - }); + model = new CounterListModel(new CounterListEvents()) + sut = render() + }) + + afterEach(cleanup) describe("when 'Add Counter' button clicked", () => { beforeEach(() => { - fireEvent.click(sut.getByText('Add Counter')); - }); + fireEvent.click(sut.getByText('Add Counter')) + }) - test("counter added", () => expect(sut.getByTestId('counter')).toBeDefined()); + test("counter added", () => expect(sut.getByTestId('counter')).toBeDefined()) test("when 'Remove' button clicked, counter removed", () => { - fireEvent.click(sut.getByText('Remove')); + fireEvent.click(sut.getByText('Remove')) - expect(sut.queryByTestId('counter')).toBeNull(); - }); - }); -}); + expect(sut.queryByTestId('counter')).toBeNull() + }) + }) +}) describe("CounterModel", () => { - let id: number; - let initialCount: number; - let parentEvents: any; - let events: CounterEvents; - let sut: ReturnType>; + let id: number + let initialCount: number + let parentEvents: any + let events: CounterEvents + let sut: Mutable beforeEach(() => { - id = 1; - initialCount = 3; - parentEvents = eventProxy(); - events = new CounterEvents(parentEvents, { id }); - sut = mutable(new CounterModel(events, { id, count: initialCount })); - }); + id = 1 + initialCount = 3 + parentEvents = eventProxy() + events = new CounterEvents(parentEvents, { id }) + sut = mutable(new CounterModel(events, { id, count: initialCount })) + }) describe("count", () => { - test("has initial count by default", () => expect(sut.count).toBe(3)); + test("has initial count by default", () => expect(sut.count).toBe(3)) test("when counter incremented, is increased by 1", () => { - parentEvents.incremented({ id }); + parentEvents.incremented({ id }) - expect(sut.count).toBe(initialCount + 1); - }); + expect(sut.count).toBe(initialCount + 1) + }) test("when counter decremented, is decreased by 1", () => { - parentEvents.decremented({ id }); + parentEvents.decremented({ id }) - expect(sut.count).toBe(initialCount - 1); - }); + expect(sut.count).toBe(initialCount - 1) + }) test("when counter reset, becomes 0", () => { - parentEvents.reset({ id }); + parentEvents.reset({ id }) - expect(sut.count).toBe(0); - }); + expect(sut.count).toBe(0) + }) describe("when value fetched asynchronously", () => { - let request: Promise; - let resolve: (value: number) => void; + let request: Promise + let resolve: (value: number) => void beforeEach(() => { - ({ promise: request, resolve } = Promise.withResolvers()); - events.valueFetched(request); - }); + ({ promise: request, resolve } = Promise.withResolvers()) + events.valueFetched(request) + }) - test("remains the same", () => expect(sut.count).toBe(initialCount)); + test("remains the same", () => expect(sut.count).toBe(initialCount)) test("when value returned, becomes the result", async () => { - let answer = 42; - resolve(answer); - await request; + let answer = 42 + resolve(answer) + await request - expect(sut.count).toBe(answer); - }); - }); - }); + expect(sut.count).toBe(answer) + }) + }) + }) describe("countTimesTwo", () => { - test("is two times initial count by default", () => expect(sut.countTimesTwo).toBe(initialCount * 2)); + test("is two times initial count by default", () => expect(sut.countTimesTwo).toBe(initialCount * 2)) test("when count updated, is two times the new count", () => { - let newCount = 11; - sut.count = newCount; + let newCount = 11 + sut.count = newCount - expect(sut.countTimesTwo).toBe(newCount * 2); - }); - }); -}); + expect(sut.countTimesTwo).toBe(newCount * 2) + }) + }) +}) describe("Counter", () => { - let id: number; - let model: CounterModel; - let sut: ReturnType; + let id: number + let model: CounterModel + let sut: ReturnType beforeEach(() => { - id = 1; - model = new CounterModel(new CounterEvents(eventProxy(), { id }), { id, count: 3 }); - sut = render(); - }); + id = 1 + model = new CounterModel(new CounterEvents(eventProxy(), { id }), { id, count: 3 }) + sut = render() + }) + + afterEach(cleanup) - test("shows count", () => expect(sut.getByTestId('count').textContent).toBe('3')); + test("shows count", () => expect(sut.getByTestId('count').textContent).toBe('3')) - test("shows count times two", () => expect(sut.getByTestId('countTimesTwo').textContent).toBe('6')); + test("shows count times two", () => expect(sut.getByTestId('countTimesTwo').textContent).toBe('6')) test("when '+' button clicked, count incremented", () => { - fireEvent.click(sut.getByText('+')); + fireEvent.click(sut.getByText('+')) - expect(sut.getByTestId('count').textContent).toBe('4'); - }); + expect(sut.getByTestId('count').textContent).toBe('4') + }) test("when '-' button clicked, count decremented", () => { - fireEvent.click(sut.getByText('-')); + fireEvent.click(sut.getByText('-')) - expect(sut.getByTestId('count').textContent).toBe('2'); - }); + expect(sut.getByTestId('count').textContent).toBe('2') + }) test("when '0' button clicked, count reset to 0", () => { - fireEvent.click(sut.getByText('0')); + fireEvent.click(sut.getByText('0')) - expect(sut.getByTestId('count').textContent).toBe('0'); - }); + expect(sut.getByTestId('count').textContent).toBe('0') + }) test("when 'Fetch' button clicked, count set to 100", async () => { - fireEvent.click(sut.getByText('Fetch')); + fireEvent.click(sut.getByText('Fetch')) await waitFor(() => { - expect(sut.getByTestId('count').textContent).toBe('100'); - }); - }); -}); \ No newline at end of file + expect(sut.getByTestId('count').textContent).toBe('100') + }) + }) +}) diff --git a/tests/Hooks.test.ts b/tests/Hooks.test.ts index ec49518..513a3fd 100644 --- a/tests/Hooks.test.ts +++ b/tests/Hooks.test.ts @@ -1,111 +1,113 @@ -import { renderHook } from "@testing-library/react-hooks"; -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { event, model, reduce, reduced, type AsyncResult } from "event-reduce"; -import { useAsyncEvent, useDerived, useEvent, useReduced } from "event-reduce-react"; -import { mutable } from "event-reduce/lib/testing"; +import { renderHook, RenderHookResult } from "@testing-library/react" +import { beforeEach, describe, expect, test } from "bun:test" +import { useAsyncEvent, useDerived, useEvent, useReduced } from "event-reduce-react" +import { event, IEvent, model, reduce, reduced, type AsyncResult } from "event-reduce" +import { mutable } from "event-reduce/lib/testing" +import { IDerivation } from "event-reduce" +import { MockObserver } from "./MockObserver" describe("useEvent", () => { - let sut: ReturnType>>>; - let initialResult: ReturnType>; + let sut: RenderHookResult>, unknown> + let initialResult: IEvent beforeEach(() => { - sut = renderHook(() => useEvent()); - initialResult = sut.result.current; - }); + sut = renderHook(() => useEvent()) + initialResult = sut.result.current + }) test("returns an event", () => { - let eventSpy: Mock<(value: number) => void> = mock(); - initialResult.subscribe(eventSpy); + let eventSpy = new MockObserver() + initialResult.subscribe(eventSpy) - initialResult(3); + initialResult(3) - expect(eventSpy).toHaveBeenCalledWith(3); - }); + expect(eventSpy.sourceUpdated).toHaveBeenCalledWith(initialResult, 3) + }) test("when rendered again, returns the same event instance", () => { - sut.rerender(); + sut.rerender() - expect(sut.result.current).toBe(initialResult); - }); -}); + expect(sut.result.current).toBe(initialResult) + }) +}) describe("useAsyncEvent", () => { - let sut: ReturnType>>>; - let initialResult: ReturnType>; + let sut: RenderHookResult>, unknown> + let initialResult: ReturnType> beforeEach(() => { - sut = renderHook(() => useAsyncEvent()); - initialResult = sut.result.current; - }); + sut = renderHook(() => useAsyncEvent()) + initialResult = sut.result.current + }) test("returns an async event", async () => { - let eventSpy: Mock<(value: AsyncResult) => void> = mock(); - initialResult.resolved.subscribe(eventSpy); + let eventSpy = new MockObserver>() + initialResult.resolved.subscribe(eventSpy) - let promise = Promise.resolve(3); - initialResult(promise); - await promise; + let promise = Promise.resolve(3) + initialResult(promise) + await promise - expect(eventSpy).toHaveBeenCalledWith(expect.objectContaining({ result: 3 })); - }); + expect(eventSpy.sourceUpdated).toHaveBeenCalledWith(initialResult.resolved, expect.objectContaining({ result: 3 })) + }) test("when rendered again, returns the same event instance", () => { - sut.rerender(); + sut.rerender() - expect(sut.result.current).toBe(initialResult); - }); -}); + expect(sut.result.current).toBe(initialResult) + }) +}) describe("useDerived", () => { - @model class SourceModel { @reduced get value() { return reduce(1).value; } } - let source: ReturnType>; - let sut: ReturnType>>>; - let initialResult: ReturnType>; + @model class SourceModel { @reduced get value() { return reduce(1).value } } + let observable: ReturnType> + let sut: RenderHookResult, unknown> + let initialResult: IDerivation beforeEach(() => { - source = mutable(new SourceModel()); - sut = renderHook(() => useDerived(() => source.value * 2)); - initialResult = sut.result.current; - }); + observable = mutable(new SourceModel()) + sut = renderHook(() => useDerived(() => observable.value * 2)) + initialResult = sut.result.current + }) test("returns a derivation", () => { - expect(initialResult.value).toBe(2); + expect(initialResult.value).toBe(2) - source.value = 3; + observable.value = 3 - expect(initialResult.value).toBe(6); - }); + expect(initialResult.value).toBe(6) + }) test("when rendered again, returns the same derivation instance", () => { - sut.rerender(); + sut.rerender() - expect(sut.result.current).toBe(initialResult); - }); -}); + expect(sut.result.current).toBe(initialResult) + }) +}) describe("useReduced", () => { - let added: ReturnType>; - let sut: ReturnType>>>; - let initialResult: ReturnType>; + let added: ReturnType> + let sut: RenderHookResult>, unknown> + let initialResult: ReturnType> beforeEach(() => { - added = event(); + added = event() sut = renderHook(() => useReduced(1) - .on(added, (current, addedVal) => current + addedVal)); - initialResult = sut.result.current; - }); + .on(added, (current, addedVal) => current + addedVal)) + initialResult = sut.result.current + }) test("returns a reduction", () => { - expect(initialResult.value).toBe(1); + expect(initialResult.value).toBe(1) - added(2); + added(2) - expect(initialResult.value).toBe(3); - }); + expect(initialResult.value).toBe(3) + }) test("when rendered again, returns the same reduction instance", () => { - sut.rerender(); + sut.rerender() - expect(sut.result.current).toBe(initialResult); - }); -}); \ No newline at end of file + expect(sut.result.current).toBe(initialResult) + }) +}) diff --git a/tests/MockObserver.ts b/tests/MockObserver.ts new file mode 100644 index 0000000..0aa3829 --- /dev/null +++ b/tests/MockObserver.ts @@ -0,0 +1,8 @@ +import { mock } from "bun:test" +import { IObservable, IObserver } from "event-reduce" + +export class MockObserver implements IObserver { + displayName = 'mock observer' + sources = new Set>() + sourceUpdated: (observable: IObservable, value: T) => void = mock<(observable: IObservable, value: T) => void>() +} diff --git a/tests/Model.test.ts b/tests/Model.test.ts index 5f94301..709253c 100644 --- a/tests/Model.test.ts +++ b/tests/Model.test.ts @@ -1,110 +1,73 @@ -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { asyncEvent, derive, derived, event, events, extend, model, reduce, reduced, state } from "event-reduce"; -import { EventsMarkedAsStateError } from "event-reduce/lib/models"; -import { AccessedValueWithCommonSourceError, valueChanged } from "event-reduce/lib/observableValue"; +import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test" +import { asyncEvent, derive, derived, event, events, model, reduce, reduced, state } from "event-reduce" +import { EventsMarkedAsStateError, MissingModelDecoratorError } from "event-reduce/lib/models" +import { decrement, ExtendedModel } from "./Model.test/ExtendedModel" +import { increment, TestModel } from "./Model.test/TestModel" describe("models", () => { - let increment: ReturnType>; - let decrement: ReturnType>; - - beforeEach(() => { - increment = event('increment'); - decrement = event('decrement'); - }); - describe("basic model tests", () => { - @model - class TestModel { - @reduced - get property() { - return reduce(1) - .on(increment, c => c + 1) - .value; - } - - @reduced - get dependentProperty() { - return reduce(1) - .on(valueChanged(this.property), (_, p) => p) - .value; - } - - @derived - get derivedProperty() { - return this.property * 2; - } - - @derived - get bool() { return true; } - - @reduced - get basedOnDerivedProperty() { - return reduce(0) - .on(valueChanged(this.derivedProperty), (_, d) => d) - .value; - } - } + let testModel: TestModel + let extendedModel: ExtendedModel - @model - class ExtendedModel extends TestModel { - @reduced - override get property() { - return extend(super.property) - .on(decrement, c => c - 1) - .value; - } - } + beforeEach(() => { + testModel = new TestModel() + extendedModel = new ExtendedModel() + }) - let testModel: TestModel; - let extendedModel: ExtendedModel; + test("property has initial value", () => expect(testModel.property).toBe(1)) - beforeEach(() => { - testModel = new TestModel(); - extendedModel = new ExtendedModel(); - }); + test("accessor property has initial value", () => expect(testModel.accessorProp).toBe(1)) - test("property has initial value", () => expect(testModel.property).toBe(1)); + test("extended property has same initial value", () => expect(extendedModel.property).toBe(1)) - test("extended property has same initial value", () => expect(extendedModel.property).toBe(1)); + test("extended accessor property has same initial value", () => expect(extendedModel.accessorProp).toBe(1)) describe("when reduction updated", () => { beforeEach(() => { - increment(undefined); - }); + increment() + }) + + test("property value updated", () => expect(testModel.property).toBe(2)) - test("property value updated", () => expect(testModel.property).toBe(2)); + test("accessor property value updated", () => expect(testModel.accessorProp).toBe(2)) - test("dependent property value updated", () => expect(testModel.dependentProperty).toBe(2)); + test("dependent property value updated", () => expect(testModel.dependentProperty).toBe(2)) - test("extended property value updated", () => expect(extendedModel.property).toBe(2)); + test("extended property value updated", () => expect(extendedModel.property).toBe(2)) - test("derived property value updated", () => expect(testModel.derivedProperty).toBe(4)); + test("extended accessor property value updated", () => expect(extendedModel.accessorProp).toBe(2)) - test("property based on derived value updated", () => expect(testModel.basedOnDerivedProperty).toBe(4)); - }); + test("derived property value updated", () => expect(testModel.derivedProperty).toBe(4)) + + test("property based on derived value updated", () => expect(testModel.basedOnDerivedProperty).toBe(4)) + }) describe("when extended reduction updated", () => { beforeEach(() => { - decrement(undefined); - }); + decrement() + }) + + test("property value unaffected", () => expect(testModel.property).toBe(1)) + + test("accessor property value unaffected", () => expect(testModel.accessorProp).toBe(1)) - test("property value unaffected", () => expect(testModel.property).toBe(1)); + test("extended property value updated", () => expect(extendedModel.property).toBe(0)) - test("extended property value updated", () => expect(extendedModel.property).toBe(0)); - }); + test("extended accessor property value updated", () => expect(extendedModel.accessorProp).toBe(0)) + }) - test("when derivation creates a new model that accesses an observable value in its constructor, accessed observable value is not counted as a source for the derivation", () => { + test("when derivation creates a new model that accesses an observable value in its constructor, accessed observable value is not counted as an observable for the derivation", () => { @model class DerivedModel { constructor() { - testModel.property; + testModel.property } } - let derivation = derive(() => new DerivedModel()); - derivation.update(); + let derivation = derive(() => new DerivedModel()) + derivation.update() - expect(derivation.sources.length).toBe(0); - }); + expect([...derivation.getSources()]).toHaveLength(0) + }) test("when reducer creates a new model that observes the same event that created it, doesn't throw", () => { @model @@ -113,112 +76,145 @@ describe("models", () => { get child() { return reduce(null as TestModel | null) .on(increment, () => new TestModel()) - .value; + .value } } - let parentModel = new Parent(); + let parentModel = new Parent() - increment(undefined); - expect(parentModel.child).toBeInstanceOf(TestModel); - }); - - test("when initial value of a model's reduced property is derived from the same event that's creating the model, throws", () => { - reduce(null as ChildModel | null) - .on(increment, () => new ChildModel()); + increment(undefined) + expect(parentModel.child).toBeInstanceOf(TestModel) + }) + test("when event is marked as state, throws when constructed", () => { @model - class ChildModel { + class BadModel { + @state + event = event('bad state') + } + + try { + new BadModel() + expect.unreachable("Should have thrown") + } catch (e) { + expect(e).toBeInstanceOf(EventsMarkedAsStateError) + expect((e as EventsMarkedAsStateError).model).toBeDefined() + expect((e as EventsMarkedAsStateError).property).toBe('event') + } + }) + + test("when class is not decorated with model, @reduced throws when constructed", () => { + class Bad { @reduced - get property() { - return reduce(testModel.property) - .on(decrement, c => c - 1) - .value; + get x() { + return reduce(1).value } } - expect(() => increment(undefined)).toThrow(AccessedValueWithCommonSourceError); - }); + expect(() => new Bad()).toThrow(MissingModelDecoratorError) + }) - test("when event is marked as state, throws when constructed", () => { - @model - class BadModel { + test("when class is not decorated with model, @derived throws when constructed", () => { + class Bad { + @derived + get x() { + return 1 + } + } + + expect(() => new Bad()).toThrow(MissingModelDecoratorError) + }) + + test("when class is not decorated with model, @state throws when constructed", () => { + class Bad { @state - event = event('bad state'); + x = 1 } - try { - new BadModel(); - expect.unreachable("Should have thrown"); - } catch (e) { - expect(e).toBeInstanceOf(EventsMarkedAsStateError); - expect((e as EventsMarkedAsStateError).model).toBeDefined(); - expect((e as EventsMarkedAsStateError).property).toBe('event'); + expect(() => new Bad()).toThrow(MissingModelDecoratorError) + }) + + test("when base class defines toJSON, model decorator does not override it", () => { + class BaseModel { + toJSON() { + return { serializedValue: "base serializer" } + } } - }); - }); -}); + + @model + class DerivedModel extends BaseModel { + @state + value = 1 + } + + let sut = new DerivedModel() + + expect(sut.toJSON()).toEqual({ serializedValue: "base serializer" }) + expect(JSON.stringify(sut)).toBe(JSON.stringify({ serializedValue: "base serializer" })) + }) + }) +}) describe("events decorator", () => { - let getterSpy: Mock<() => void>; + let getterSpy: Mock<() => void> @events class TestEvents { - eventField = asyncEvent(); + eventField = asyncEvent() get eventGetter() { - getterSpy(); - return asyncEvent(); + getterSpy() + return asyncEvent() } } - let sut: TestEvents; + let sut: TestEvents beforeEach(() => { - getterSpy = mock(); - sut = new TestEvents(); - }); + getterSpy = mock() + sut = new TestEvents() + }) - test("keeps class name", () => expect(TestEvents.name).toBe('TestEvents')); + test("keeps class name", () => expect(TestEvents.name).toBe('TestEvents')) test("sets event name", () => { - expect(sut.eventField.displayName).toBe('eventField'); - expect(sut.eventField.started.displayName).toBe('eventField.started'); - expect(sut.eventField.resolved.displayName).toBe('eventField.resolved'); - expect(sut.eventField.rejected.displayName).toBe('eventField.rejected'); - - expect(sut.eventGetter.displayName).toBe('eventGetter'); - expect(sut.eventGetter.started.displayName).toBe('eventGetter.started'); - expect(sut.eventGetter.resolved.displayName).toBe('eventGetter.resolved'); - expect(sut.eventGetter.rejected.displayName).toBe('eventGetter.rejected'); - }); - - test("sets event container", () => { - expect((sut.eventField as any).container).toBe(sut); - expect((sut.eventGetter as any).container).toBe(sut); - }); + expect(sut.eventField.displayName).toBe('eventField') + expect(sut.eventField.started.displayName).toBe('eventField.started') + expect(sut.eventField.resolved.displayName).toBe('eventField.resolved') + expect(sut.eventField.rejected.displayName).toBe('eventField.rejected') + + expect(sut.eventGetter.displayName).toBe('eventGetter') + expect(sut.eventGetter.started.displayName).toBe('eventGetter.started') + expect(sut.eventGetter.resolved.displayName).toBe('eventGetter.resolved') + expect(sut.eventGetter.rejected.displayName).toBe('eventGetter.rejected') + }) + + test("sets event parents", () => { + expect(sut.eventField.path).toBe('TestEvents.eventField') + expect(sut.eventGetter.path).toBe('TestEvents.eventGetter') + }) test("snapshots getter event", () => { - sut.eventGetter; - sut.eventGetter; + sut.eventGetter + sut.eventGetter - expect(getterSpy).toHaveBeenCalledTimes(1); - }); + expect(getterSpy).toHaveBeenCalledTimes(1) + }) test("when event class is marked as state, throws when constructed", () => { @model class BadModel { @state - events = new TestEvents(); + events = new TestEvents() } try { - new BadModel(); - expect.unreachable("Should have thrown"); + new BadModel() + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(EventsMarkedAsStateError); - expect((e as EventsMarkedAsStateError).model).toBeDefined(); - expect((e as EventsMarkedAsStateError).property).toBe('events'); + expect(e).toBeInstanceOf(EventsMarkedAsStateError) + expect((e as EventsMarkedAsStateError).model).toBeDefined() + expect((e as EventsMarkedAsStateError).property).toBe('events') } - }); -}); \ No newline at end of file + }) +}) diff --git a/tests/Model.test/ExtendedModel.ts b/tests/Model.test/ExtendedModel.ts new file mode 100644 index 0000000..b1428f2 --- /dev/null +++ b/tests/Model.test/ExtendedModel.ts @@ -0,0 +1,21 @@ +import { event, extendReduce, model, reduced } from "event-reduce" +import { TestModel } from "./TestModel" + +// TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved + +export let decrement = event('decrement') + +@model +export class ExtendedModel extends TestModel { + @reduced + override get property() { + return extendReduce(super.property) + .on(decrement, c => c - 1) + .value + } + + @reduced + override accessor accessorProp = extendReduce(super.accessorProp) + .on(decrement, c => c - 1) + .value +} diff --git a/tests/Model.test/TestModel.ts b/tests/Model.test/TestModel.ts new file mode 100644 index 0000000..6217d56 --- /dev/null +++ b/tests/Model.test/TestModel.ts @@ -0,0 +1,42 @@ +import { derive, derived, event, model, reduce, reduced } from "event-reduce" + +// TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved + +export let increment = event('increment') + +@model +export class TestModel { + @reduced + get property() { + return reduce(1) + .on(increment, c => c + 1) + .value + } + + @reduced + accessor accessorProp = reduce(1) + .on(increment, c => c + 1) + .value + + @reduced + get dependentProperty() { + return reduce(1) + .on(derive(() => this.property), (_, p) => p) + .value + } + + @derived + get derivedProperty() { + return this.property * 2 + } + + @derived + get bool() { return true } + + @reduced + get basedOnDerivedProperty() { + return reduce(0) + .on(derive(() => this.derivedProperty), (_, d) => d) + .value + } +} diff --git a/tests/ModelAccessor.test.ts b/tests/ModelAccessor.test.ts deleted file mode 100644 index 4ed4ac6..0000000 --- a/tests/ModelAccessor.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { beforeEach, describe, expect, test } from "bun:test"; -import { event, extend, model, reduce, reduced } from "event-reduce"; - -// In a separate file until bun supports the accessor syntax, -// at which point these can be merged back into Model.test.ts - -describe("model accessor props", () => { - let increment: ReturnType>; - let decrement: ReturnType>; - - beforeEach(() => { - increment = event('increment'); - decrement = event('decrement'); - }); - - @model - class TestModel { - @reduced - accessor accessorProp = reduce(1) - .on(increment, c => c + 1) - .value; - } - - @model - class ExtendedModel extends TestModel { - @reduced - override accessor accessorProp: number = extend(super.accessorProp) - .on(decrement, c => c - 1) - .value; - } - - let testModel: TestModel; - let extendedModel: ExtendedModel; - - beforeEach(() => { - testModel = new TestModel(); - extendedModel = new ExtendedModel(); - }); - - test("accessor property has initial value", () => expect(testModel.accessorProp).toBe(1)); - - test("extended accessor property has same initial value", () => expect(extendedModel.accessorProp).toBe(1)); - - describe("when reduction updated", () => { - beforeEach(() => { - increment(undefined); - }); - - test("accessor property value updated", () => expect(testModel.accessorProp).toBe(2)); - - test("extended accessor property value updated", () => expect(extendedModel.accessorProp).toBe(2)); - }); - - describe("when extended reduction updated", () => { - beforeEach(() => { - decrement(undefined); - }); - - test("accessor property value unaffected", () => expect(testModel.accessorProp).toBe(1)); - - test("extended accessor property value updated", () => expect(extendedModel.accessorProp).toBe(0)); - }); -}); \ No newline at end of file diff --git a/tests/Observable.test.ts b/tests/Observable.test.ts index 759c392..1ac2d02 100644 --- a/tests/Observable.test.ts +++ b/tests/Observable.test.ts @@ -1,73 +1,69 @@ -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { IObservable, IObserver, merge, Subject, Unsubscribe } from 'event-reduce'; -import { ObservableOperation } from 'event-reduce/lib/observable'; - -describe("ObservableOperation", () => { - describe("when subscribing", () => { - let unsubscribe: Mock; - let subscribe: Mock<(observer: IObserver) => Unsubscribe>; - let observer: Mock<(value: any) => void>; - let sut: ObservableOperation; - let result: () => void; - - beforeEach(() => { - unsubscribe = mock(); - subscribe = mock(() => unsubscribe); - observer = mock(); - sut = new ObservableOperation(() => 'test', [], subscribe); - result = sut.subscribe(observer); - }); - - test("subscribe function called with observer", () => { - expect(subscribe).toHaveBeenCalledWith(expect.objectContaining({ - getDisplayName: expect.any(Function), - next: expect.any(Function) - })); - }); - - test("when unsubscribed, unsubscribed from inner subscription", () => { - result(); - - expect(unsubscribe).toHaveBeenCalled(); - }); - }); +import { beforeEach, describe, expect, test } from "bun:test" +import { ISignal, signal, type IObservable } from "event-reduce" +import { MockObserver } from "./MockObserver" - test("filter", () => { - let source = observableOf(1, 2, 3, 2, 1); - let result = source.filter(v => v > 1); - expect(values(result)).toEqual([2, 3, 2]); - }); +describe("Observable", () => { + let sut: ISignal + let observer: MockObserver + + beforeEach(() => { + sut = signal('initial', 'sut') + observer = new MockObserver() + sut.subscribe(observer) + }) + + test("when updated, notifies subscribed observers", () => { + sut.setValue('updated') + + expect(observer.sourceUpdated).toHaveBeenCalledWith(sut, 'updated') + }) + + test("when unsubscribed, stops notifying observer", () => { + sut.unsubscribe(observer) + + sut.setValue('updated') + + expect(observer.sourceUpdated).not.toHaveBeenCalled() + }) test("map", () => { - let source = observableOf(1, 2, 3); - let result = source.map(v => v * 2); - expect(values(result)).toEqual([2, 4, 6]); - }); - - test("merge", () => { - let source1 = new Subject(() => 'source 1'); - let source2 = new Subject(() => 'source 2'); - let result = merge([source1, source2]); - let vals = [] as number[]; - result.subscribe(v => vals.push(v)); - - source1.next(1); - source2.next(2); - source1.next(3); - - expect(vals).toEqual([1, 2, 3]); - }); - - function observableOf(...args: T[]) { - return new ObservableOperation(() => 'source', [], observer => { - args.forEach(a => observer.next(a)); - return () => { } - }); - } + let mapped = sut.map(value => value.toUpperCase()) + let observed = values(mapped) + + sut.setValue('a') + sut.setValue('b') + + expect(observed).toEqual(['A', 'B']) + }) + + test("filter", () => { + let filtered = sut.filter(value => value.length > 1) + let observed = values(filtered) + + sut.setValue('a') + sut.setValue('bb') + sut.setValue('ccc') + + expect(observed).toEqual(['bb', 'ccc']) + }) + + test("filterMap", () => { + let filtered = sut.filterMap(value => value.length > 1 ? value.length : undefined) + let observed = values(filtered) + + sut.setValue('a') + sut.setValue('bb') + sut.setValue('cccc') + + expect(observed).toEqual([2, 4]) + }) function values(observable: IObservable) { - let vals = [] as T[]; - observable.subscribe(value => vals.push(value)); - return vals; + let observed: T[] = [] + observable.subscribe({ + displayName: 'value collector', + sourceUpdated: (_, value) => observed.push(value) + }) + return observed } -}); +}) \ No newline at end of file diff --git a/tests/ObservableValue.test.ts b/tests/ObservableValue.test.ts index e976257..8223d82 100644 --- a/tests/ObservableValue.test.ts +++ b/tests/ObservableValue.test.ts @@ -1,58 +1,43 @@ -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { ObservableValue, collectAccessedValues, consumeLastAccessed } from "event-reduce/lib/observableValue"; +import { beforeEach, describe, expect, test } from "bun:test" +import { ISignal, signal } from "event-reduce" +import { getUnderlyingObservable } from "event-reduce/lib/tracking" +import { MockObserver } from "./MockObserver" -describe("ObservableValue", () => { - let sut: ObservableValue; - let observer: Mock<(value: string) => void>; +describe("signal", () => { + let sut: ISignal + let observer: MockObserver beforeEach(() => { - sut = new ObservableValue(() => 'sut', 'initial'); - observer = mock(); - sut.subscribe(observer); - }); + sut = signal('initial', 'sut') + observer = new MockObserver() + sut.subscribe(observer) + }) describe("when value accessed", () => { - let result: string; + let result: string beforeEach(() => { - result = sut.value; - }); + result = sut.value + }) - test("returns provided value", () => expect(result).toBe('initial')); + test("returns provided value", () => expect(result).toBe('initial')) - test("is last accessed value", () => expect(consumeLastAccessed()!).toBe(sut)); - }); + test("tracks the accessed observable", () => expect(getUnderlyingObservable(result)).toBe(sut)) + }) describe("when value changed", () => { beforeEach(() => { - sut.setValue('different'); - }); + sut.setValue('different') + }) - test("value updated", () => expect(sut.value).toBe('different')); + test("updates the value", () => expect(sut.value).toBe('different')) - test("observers notified", () => expect(observer).toHaveBeenCalled()); - }); + test("notifies observers", () => expect(observer.sourceUpdated).toHaveBeenCalledWith(sut, 'different')) + }) - test("when value set to same value, observers not notified", () => { - sut.setValue('initial'); + test("when value set to same value, observers are notified", () => { + sut.setValue('initial') - expect(observer).not.toHaveBeenCalled(); - }); -}); - -describe("collectAccessedValues", () => { - let valueA: ObservableValue; - let valueB: ObservableValue; - - beforeEach(() => { - valueA = new ObservableValue(() => 'a', 'a'); - valueB = new ObservableValue(() => 'b', 'b'); - }); - - test("when multiple values accessed, all accessed values returned", () => { - let result = collectAccessedValues(() => valueA.value + valueB.value); - - expect(Array.from(result)).toContain(valueA); - expect(Array.from(result)).toContain(valueB); - }); -}); \ No newline at end of file + expect(observer.sourceUpdated).toHaveBeenCalledWith(sut, 'initial') + }) +}) \ No newline at end of file diff --git a/tests/Reduction.test.ts b/tests/Reduction.test.ts index 773e35d..010fd84 100644 --- a/tests/Reduction.test.ts +++ b/tests/Reduction.test.ts @@ -1,177 +1,202 @@ -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { event, events, reduce } from 'event-reduce'; -import { AccessedValueWithCommonSourceError, collectAccessedValues } from 'event-reduce/lib/observableValue'; -import { CircularSubscriptionError, IBoundReduction, IReduction, ReducedEventsError } from "event-reduce/lib/reduction"; -import { Subject } from 'event-reduce/lib/subject'; +import { afterEach, beforeEach, describe, expect, mock, test, type Mock } from "bun:test" +import { createModel, event, events, IReduction, ISignal, reduce, signal } from 'event-reduce' +import { InvalidStateError } from "event-reduce/lib/observable" +import { CircularSubscriptionError } from "event-reduce/lib/reduction" +import { CircularDependencyError, collectAccessedSources } from "event-reduce/lib/tracking" +import { dispose } from "event-reduce/lib/utils" +import { MockObserver } from "./MockObserver" describe("reduce", () => { describe("when unbound", () => { - let subscriber: Mock<(value: number) => void>; - let sut: IReduction; + let subscriber: MockObserver + let sut: IReduction beforeEach(() => { - subscriber = mock(); - sut = reduce(1, 'sut'); - sut.subscribe(subscriber); - }); + subscriber = new MockObserver() + sut = createModel(() => reduce(1, 'sut')) + sut.subscribe(subscriber) + }) - test("starts with initial value", () => expect(sut.value).toBe(1)); + afterEach(() => { + sut[dispose]() + }) + + test("starts with initial value", () => expect(sut.value).toBe(1)) describe("when subscribed to an observable", () => { - let subject: Subject; - let reducer: Mock<(previous: number, event: string) => number>; + let observableValue: ISignal + let reducer: Mock<(previous: number, event: string) => number> beforeEach(() => { - subject = new Subject(() => 'test'); - reducer = mock(); - sut.on(subject, reducer); - }); + observableValue = signal('test', 'test') + reducer = mock() + sut.on(observableValue, reducer) + }) describe("when observable emits a value", () => { beforeEach(() => { - reducer.mockReturnValue(3); - subject.next('foo'); - }); + reducer.mockReturnValue(3) + observableValue.setValue('foo') + }) test("reducer called with previous value and observable value", () => { - expect(reducer).toHaveBeenCalledWith(1, 'foo'); - }); + expect(reducer).toHaveBeenCalledWith(1, 'foo') + }) - test("value becomes return value of reducer", () => expect(sut.value).toBe(3)); + test("value becomes return value of reducer", () => expect(sut.value).toBe(3)) describe("when another value is emitted", () => { beforeEach(() => { - reducer.mockReturnValue(4); - subject.next('bar'); - }); + reducer.mockReturnValue(4) + observableValue.setValue('bar') + }) test("reducer called with previous value and observable value", () => { - expect(reducer).toHaveBeenCalledWith(3, 'bar'); - }); + expect(reducer).toHaveBeenCalledWith(3, 'bar') + }) - test("value becomes return value of reducer", () => expect(sut.value).toBe(4)); - }); - }); - }); + test("value becomes return value of reducer", () => expect(sut.value).toBe(4)) + }) + }) + }) describe("when a reducer accesses a reduced value", () => { - let other: ReturnType>; - let subject: Subject; + let other: ReturnType> + let observable: ISignal beforeEach(() => { - other = reduce(0); - subject = new Subject(() => 'test'); - sut.on(subject, () => other.value); - }); + other = createModel(() => reduce(0)) + observable = signal(0, 'test') + sut.on(observable, () => other.value) + }) + + afterEach(() => { + other[dispose]() + }) + + test("when other value based on same source, uses latest value", () => { + other.on(observable, (_, value) => value) - test("when other value based on same event, throws", () => { - other.on(subject, () => 0); + observable.setValue(3) + + expect(other.value).toBe(3) + expect(sut.value).toBe(3) + }) + + test("when other value also accesses current reduction, throws", () => { + other.on(observable, () => sut.value) try { - subject.next(0); - expect.unreachable("Should have thrown"); + observable.setValue(1) + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(AccessedValueWithCommonSourceError); - expect((e as AccessedValueWithCommonSourceError).commonSource).toBe(subject); - expect((e as AccessedValueWithCommonSourceError).triggeringObservable).toBe(subject); - expect((e as AccessedValueWithCommonSourceError).accessedObservable).toBe(other as any); + expect(e).toBeInstanceOf(CircularDependencyError) } - }); + }) test("when other value not based on same event, doesn't throw", () => { - subject.next(0); - }); - }); + observable.setValue(0) + }) + }) test("when subscribing to an observable based on itself, throws", () => { - let observable = sut.filter(n => n > 3); + let observable = sut.filter(n => n > 3) try { - sut.on(observable, (_, n) => n); - expect.unreachable("Should have thrown"); + sut.on(observable, (_, n) => n) + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(CircularSubscriptionError); - expect((e as CircularSubscriptionError).observable).toBe(observable); - expect((e as CircularSubscriptionError).reduction).toBe(sut); + expect(e).toBeInstanceOf(CircularSubscriptionError) + expect((e as CircularSubscriptionError).source).toBe(observable) + expect((e as CircularSubscriptionError).reduction).toBe(sut) } - }); - }); + }) + }) describe("when bound to events object", () => { - let eventsObj: {}; - let sut: IBoundReduction; + let eventsObj: {} + let sut: IReduction beforeEach(() => { - eventsObj = {}; - sut = reduce(1, eventsObj); - }); + eventsObj = {} + sut = createModel(() => reduce(1, eventsObj)) + }) + + afterEach(() => { + sut[dispose]() + }) describe("when subscribing to an observable", () => { - let subject: Subject; - let getEvent: Mock<(events: typeof eventsObj) => Subject>; - let reducer: Mock<(previous: number, event: string) => number>; + let observableValue: ISignal + let getEvent: Mock<(events: typeof eventsObj) => ISignal> + let reducer: Mock<(previous: number, event: string) => number> beforeEach(() => { - subject = new Subject(() => 'test'); - getEvent = mock(() => subject); - reducer = mock(); - sut.on(getEvent, reducer); - }); + observableValue = signal('test', 'test') + getEvent = mock(() => observableValue) + reducer = mock() + sut.on(getEvent, reducer) + }) test("event getter called with bound events", () => { - expect(getEvent).toHaveBeenCalledWith(eventsObj); - }); + expect(getEvent).toHaveBeenCalledWith(eventsObj) + }) test("reduction subscribed to result of event getter", () => { - subject.next('foo'); - expect(reducer).toHaveBeenCalledWith(1, 'foo'); - }); - }); - }); + observableValue.setValue('foo') + expect(reducer).toHaveBeenCalledWith(1, 'foo') + }) + }) + }) test("accessed reductions updated when value is accessed", () => { - let r1 = reduce(1); - let r2 = reduce(2); + using sut = createModel(() => ({ + r1: reduce(1, 'r1'), + r2: reduce(2, 'r2') + })) - let accessed = collectAccessedValues(() => { - r1.value; - r2.value; - }); + using tracker = collectAccessedSources(null) + sut.r1.value + sut.r2.value - expect(Array.from(accessed)).toContain(r1 as any); - expect(Array.from(accessed)).toContain(r2 as any); - }); + expect(tracker.accessed).toContain(sut.r1 as any) + expect(tracker.accessed).toContain(sut.r2 as any) + }) test("when reducer returns an event, throws", () => { - let eventValue = event('test event'); - let subject = new Subject(() => 'test'); - let sut = reduce(null as any, 'sut') - .on(subject, () => eventValue); + let eventValue = event('test event') + let subject = signal('test', 'test') + using sut = createModel(() => ({ + reduction: reduce(null as any, 'sut') + .on(subject, () => eventValue) + })) try { - subject.next('foo'); - expect.unreachable("Should have thrown"); + subject.setValue('foo') + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(ReducedEventsError); - expect((e as ReducedEventsError).reduction).toBe(sut); - expect((e as ReducedEventsError).value).toBe(eventValue); + expect(e).toBeInstanceOf(InvalidStateError) + expect((e as InvalidStateError).observable).toBe(sut.reduction) + expect((e as InvalidStateError).value).toBe(eventValue) } - }); + }) test("when reducer returns an events object, throws", () => { @events class Events { } - let eventsValue = new Events(); - let subject = new Subject(() => 'test'); - let sut = reduce(null as any, 'sut') - .on(subject, () => eventsValue); + let eventsValue = new Events() + let subject = signal('test', 'test') + using sut = createModel(() => ({ + reduction: reduce(null as any, 'sut') + .on(subject, () => eventsValue) + })) try { - subject.next('foo'); - expect.unreachable("Should have thrown"); + subject.setValue('foo') + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(ReducedEventsError); - expect((e as ReducedEventsError).reduction).toBe(sut); - expect((e as ReducedEventsError).value).toBe(eventsValue); + expect(e).toBeInstanceOf(InvalidStateError) + expect((e as InvalidStateError).observable).toBe(sut.reduction) + expect((e as InvalidStateError).value).toBe(eventsValue) } - }); -}); \ No newline at end of file + }) +}) diff --git a/tests/Rendering.test.ts b/tests/Rendering.test.ts index f615b79..27650ce 100644 --- a/tests/Rendering.test.ts +++ b/tests/Rendering.test.ts @@ -1,49 +1,41 @@ -import { act, renderHook } from "@testing-library/react-hooks"; -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { useReactive } from "event-reduce-react"; -import { ObservableValue } from "event-reduce/lib/observableValue"; +import { act, renderHook } from "@testing-library/react" +import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test" +import { ISignal, signal } from "event-reduce" +import { useReactive } from "event-reduce-react" describe('useReactive', () => { - let observableValue: ObservableValue; - let innerBehaviour: () => void; - let render: Mock<() => string>; - let sut: ReturnType>; + let observable: ISignal + let innerBehaviour: () => void + let render: Mock<() => string> + let sut: ReturnType> beforeEach(() => { - observableValue = new ObservableValue(() => 'test value', 'initial value'); - innerBehaviour = () => { }; + observable = signal('initial value', 'test value') + innerBehaviour = () => { } render = mock(() => { - innerBehaviour(); - return observableValue.value; - }); - sut = renderHook(() => useReactive(render)); - }); + innerBehaviour() + return observable.value + }) + sut = renderHook(() => useReactive(render)) + }) - test("render function called immediately", () => expect(render).toHaveBeenCalledTimes(1)); + test("render function called immediately", () => expect(render).toHaveBeenCalledTimes(1)) - test("returns result of render function", () => expect(sut.result.current).toBe('initial value')); + test("returns result of render function", () => expect(sut.result.current).toBe('initial value')) test("when re-rendered, render function called again", () => { - sut.rerender(); + sut.rerender() - expect(render).toHaveBeenCalledTimes(2); // In case render function uses hooks - }); + expect(render).toHaveBeenCalledTimes(2) // In case render function uses hooks + }) describe("when accessed observable value changed", () => { beforeEach(() => { - act(() => observableValue.setValue('new value')); - }); + act(() => observable.setValue('new value')) + }) - test("render function called again", () => expect(render).toHaveBeenCalledTimes(2)); + test("render function called again", () => expect(render).toHaveBeenCalledTimes(2)) - test("returns updated result", () => expect(sut.result.current).toBe('new value')); - }); - - test("when accessed observable value is changed during render, doesn't trigger an extra render", () => { - innerBehaviour = () => observableValue.setValue('new value'); - render.mockClear(); - sut.rerender(); - - expect(render).toHaveBeenCalledTimes(1); - }); -}); \ No newline at end of file + test("returns updated result", () => expect(sut.result.current).toBe('new value')) + }) +}) diff --git a/tests/State.test.ts b/tests/State.test.ts index 0b2aac3..ee6c888 100644 --- a/tests/State.test.ts +++ b/tests/State.test.ts @@ -1,101 +1,45 @@ -import { describe, test, expect, beforeEach } from "bun:test"; -import { derived, getState, model, reduce, reduced, setState, state, State } from "event-reduce"; - -describe("state", () => { - @model - @state('constructorProperty') - class BaseModel { - constructor(public constructorProperty: string) { } - - @reduced - get valueProperty() { return reduce(1).value; } - - @derived - get valuePlusOne() { - return this.valueProperty + 1; - } - - func() { } - - @reduced - get reducedModel() { return reduce(new ChildModel('child')).value; } - - @reduced - get modelArray() { - return reduce([new ChildModel('one'), new ChildModel('two')]) - .onRestore((_, arr) => arr.map(c => new ChildModel(c.value))) - .value; - } - - @state - mergedModel = new ChildModel('merged'); - - ignoredValue = 'ignored'; - } - - @model - class TestModel extends BaseModel { - @state - subClassProperty = 'subClass'; - } - - @model - class ChildModel { - constructor(private _initialValue: string) { } - - @reduced - get value() { return reduce(this._initialValue).value; } - } - - let testModel: TestModel; - - beforeEach(() => { - testModel = new TestModel('ctor'); - }); - - test("getState copies the correct properties", () => { - let result = getState(testModel); - expect(JSON.stringify(result)).toBe(JSON.stringify({ - valueProperty: 1, - reducedModel: { value: 'child' }, - modelArray: [ - { value: 'one' }, - { value: 'two' } - ], - constructorProperty: 'ctor', - mergedModel: { value: 'merged' }, - subClassProperty: 'subClass' - })); - }); - - test("setState updates properties correctly", () => { - let originalMergedModel = testModel.mergedModel; - let stateValue = { - valueProperty: 2, - reducedModel: { value: 'child*' }, - modelArray: [ - { value: 'one*' }, - { value: 'two*' }, - { value: 'three*' } - ], - mergedModel: { value: 'merged*' }, - subClassProperty: 'subClass*', - ignoredValue: 'ignored*' - } as State; - setState(testModel, stateValue); - - expect(testModel.valueProperty).toBe(2); - expect(testModel.reducedModel).toBeInstanceOf(ChildModel); - expect(testModel.reducedModel.value).toBe('child*'); - expect(testModel.modelArray[0]).toBeInstanceOf(ChildModel); - expect(testModel.modelArray[1]).toBeInstanceOf(ChildModel); - expect(testModel.modelArray[2]).toBeInstanceOf(ChildModel); - expect(testModel.modelArray[0].value).toBe('one*'); - expect(testModel.modelArray[1].value).toBe('two*'); - expect(testModel.modelArray[2].value).toBe('three*'); - expect(testModel.mergedModel).toBe(originalMergedModel); - expect(testModel.mergedModel.value).toBe('merged*'); - expect(testModel.subClassProperty).toBe('subClass*'); - expect(testModel.ignoredValue).toBe('ignored'); - }); -}); \ No newline at end of file +import { describe, expect, test } from "bun:test" +import { getState, stringifyState } from "event-reduce" +import { TestModel } from "./State.test/TestModel" + +describe("state", () => { + test("getState copies the correct properties", () => { + let testModel = new TestModel('ctor') + let result = getState(testModel) + expect(JSON.stringify(result)).toBe(JSON.stringify({ + valueProperty: 1, + reducedModel: { value: 'child' }, + modelArray: [ + { value: 'one' }, + { value: 'two' } + ], + mergedModel: { value: 'merged' }, + subClassProperty: 'subClass', + constructorProperty: 'ctor', + })) + }) + + test("getState can include derived properties", () => { + let testModel = new TestModel('ctor') + let result = getState(testModel, true) + expect(JSON.stringify(result)).toBe(JSON.stringify({ + valueProperty: 1, + valuePlusOne: 2, + reducedModel: { value: 'child' }, + modelArray: [ + { value: 'one' }, + { value: 'two' } + ], + mergedModel: { value: 'merged' }, + subClassProperty: 'subClass', + constructorProperty: 'ctor', + })) + }) + + test("includeDerived is not cached between calls", () => { + let testModel = new TestModel('ctor') + stringifyState(testModel) + let result = stringifyState(testModel, true) + expect(result).toContain('"valuePlusOne":2') + }) +}) diff --git a/tests/State.test/BaseModel.ts b/tests/State.test/BaseModel.ts new file mode 100644 index 0000000..3fdef2a --- /dev/null +++ b/tests/State.test/BaseModel.ts @@ -0,0 +1,32 @@ +import { derived, model, reduce, reduced, state } from "event-reduce" +import { ChildModel } from "./ChildModel" + +// TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved + +@model +@state('constructorProperty') +export class BaseModel { + constructor(public constructorProperty: string) { } + + @reduced + get valueProperty() { return reduce(1).value } + + @derived + get valuePlusOne() { return this.valueProperty + 1 } + + func() { } + + @reduced + get reducedModel() { return reduce(new ChildModel('child')).value } + + @reduced + get modelArray() { + return reduce([new ChildModel('one'), new ChildModel('two')]) + .value + } + + @state + mergedModel = new ChildModel('merged') + + ignoredValue = 'ignored' +} diff --git a/tests/State.test/ChildModel.ts b/tests/State.test/ChildModel.ts new file mode 100644 index 0000000..c2c1f0c --- /dev/null +++ b/tests/State.test/ChildModel.ts @@ -0,0 +1,11 @@ +import { model, reduce, reduced } from "event-reduce" + +// TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved + +@model +export class ChildModel { + constructor(private _initialValue: string) { } + + @reduced + get value() { return reduce(this._initialValue).value } +} diff --git a/tests/State.test/TestModel.ts b/tests/State.test/TestModel.ts new file mode 100644 index 0000000..bec02dc --- /dev/null +++ b/tests/State.test/TestModel.ts @@ -0,0 +1,10 @@ +import { model, state } from "event-reduce" +import { BaseModel } from "./BaseModel" + +// TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved + +@model +export class TestModel extends BaseModel { + @state + subClassProperty = 'subClass' +} diff --git a/tests/Subject.test.ts b/tests/Subject.test.ts deleted file mode 100644 index 43b86d7..0000000 --- a/tests/Subject.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { Subject } from 'event-reduce'; - -describe("Subject", () => { - let sut: Subject; - - beforeEach(() => { - sut = new Subject(() => 'test'); - }); - - describe("when subscribed to", () => { - let observer1: Mock<(value: number) => void>; - let observer2: Mock<(value: number) => void>; - let unsub1: () => void; - let unsub2: () => void; - - beforeEach(() => { - observer1 = mock(); - observer2 = mock(); - unsub1 = sut.subscribe(observer1); - unsub2 = sut.subscribe(observer2); - }); - - test("when provided a value, passes value to subscribers", () => { - sut.next(1); - - expect(observer1).toHaveBeenCalledWith(1); - expect(observer2).toHaveBeenCalledWith(1); - }); - - describe("when unsubscribed", () => { - beforeEach(() => { - unsub1(); - }); - - test("when provided a value, doesn't pass value to unsubscribed subscriber", () => { - sut.next(2); - - expect(observer1).not.toHaveBeenCalled(); - expect(observer2).toHaveBeenCalledWith(2); - }); - }); - }); -}); diff --git a/tests/Testing.test.ts b/tests/Testing.test.ts index d5f55a3..7a9b577 100644 --- a/tests/Testing.test.ts +++ b/tests/Testing.test.ts @@ -1,70 +1,70 @@ -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { derived, event, events, model, reduce, reduced } from "event-reduce"; -import { disposeModel } from "event-reduce/lib/cleanup"; -import { eventProxy, modelProxy, mutable } from "event-reduce/lib/testing"; +import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test" +import { derived, disposeModel, event, events, model, reduce, reduced } from "event-reduce" +import { eventProxy, getTarget, modelProxy, Mutable, mutable } from "event-reduce/lib/testing" +import { MockObserver } from "./MockObserver" describe("mutable", () => { @events class Events { - nextValue = event(); + nextValue = event() } @model class Model { - private privateVal = 'private'; + private privateVal = 'private' - events = new Events(); + events = new Events() @reduced get value() { return reduce(0) .on(this.events.nextValue, (_, val) => val) - .value; + .value } @derived - get valuePlusOne() { return this.value + 1; } + get valuePlusOne() { return this.value + 1 } } - let baseModel: Model; - let sut: ReturnType>; - let typedModel: Model; + let baseModel: Model + let sut: Mutable + let typedModel: Model beforeEach(() => { - baseModel = new Model(); - baseModel.valuePlusOne; - sut = mutable(baseModel); - typedModel = sut.target; - }); + baseModel = new Model() + baseModel.valuePlusOne + sut = mutable(baseModel) + typedModel = sut.target + }) test("without overrides, properties behave normally", () => { - sut.events.nextValue(3); + sut.events.nextValue(3) - expect(sut.value).toBe(3); - expect(sut.valuePlusOne).toBe(4); - expect(sut.valuePlusOne).toBe(4); - }); + expect(sut.value).toBe(3) + expect(sut.valuePlusOne).toBe(4) + expect(sut.valuePlusOne).toBe(4) + }) describe("when reduced property overridden", () => { beforeEach(() => { - sut.value = 2; - }); + sut.value = 2 + }) - test("reduced value returns override", () => expect(sut.value).toBe(2)); + test("reduced value returns override", () => expect(sut.value).toBe(2)) - test("computed value is still computed", () => expect(sut.valuePlusOne).toBe(3)); - }); + test("computed value is still computed", () => expect(sut.valuePlusOne).toBe(3)) + }) test("when computed property overridden, computed value returns override", () => { - sut.valuePlusOne = 3; + sut.valuePlusOne = 3 - expect(sut.valuePlusOne).toBe(3); - }); + expect(sut.valuePlusOne).toBe(3) + }) test("can be disposed", () => { - disposeModel(sut); - }); -}); + disposeModel(sut) + }) +}) describe("modelProxy", () => { class ModelClass { @@ -74,11 +74,11 @@ describe("modelProxy", () => { ) { } } - let sut: ModelClass; + let sut: ModelClass beforeEach(() => { - sut = modelProxy(new ModelClass('foo', { prop: 'bar' })); - }); + sut = modelProxy(new ModelClass('foo', { prop: 'bar' })) + }) test("can deeply compare proxy", () => { expect(sut).toEqual({ @@ -86,44 +86,44 @@ describe("modelProxy", () => { objectValue: { prop: 'bar' } - }); - }); + }) + }) test("can be disposed", () => { - disposeModel(sut); - }); -}); + disposeModel(sut) + }) +}) describe("eventProxy", () => { @events class Events { - nextValue = event(); + nextValue = event() } - let untyped: any; - let typed: Events; + let untyped: any + let typed: Events beforeEach(() => { - untyped = eventProxy(); - typed = eventProxy(); - }); + untyped = eventProxy() + typed = eventProxy() + }) test("arbitrary events work", () => { - let handler: Mock<(value: string) => void> = mock(); - untyped.foo.subscribe(handler); + let handler = new MockObserver() + untyped.foo.subscribe(handler) - untyped.foo('bar'); + untyped.foo('bar') - expect(handler).toHaveBeenCalledWith('bar'); - }); + expect(handler.sourceUpdated).toHaveBeenCalledWith(getTarget(untyped.foo), 'bar') + }) test("custom event creation", () => { - let custom = eventProxy(() => event<{ value: string }>().scope({ value: 'foo' })); - let handler: Mock<(value: { value: string }) => void> = mock(); - custom.foo.subscribe(handler); + let custom = eventProxy(() => event<{ value: string }>().scope({ value: 'foo' })) + let handler = new MockObserver<{ value: string }>() + custom.foo.subscribe(handler) - custom.foo({}); + custom.foo({}) - expect(handler).toHaveBeenCalledWith(expect.objectContaining({ value: 'foo' })); - }); -}); \ No newline at end of file + expect(handler.sourceUpdated).toHaveBeenCalledWith(custom.foo, expect.objectContaining({ value: 'foo' })) + }) +}) diff --git a/tests/package.json b/tests/package.json index 3a6ff68..0765b57 100644 --- a/tests/package.json +++ b/tests/package.json @@ -1,27 +1,27 @@ -{ - "name": "event-reduce-tests", - "version": "1.0.0", - "license": "MIT", - "private": true, - "author": "Simon Oxtoby", - "homepage": "https://github.com/soxtoby/event-reduce", - "repository": { - "type": "git", - "url": "https://github.com/soxtoby/event-reduce.git" - }, - "dependencies": { - "event-reduce": "*", - "event-reduce-react": "*" - }, - "devDependencies": { - "@happy-dom/global-registrator": "^20.6.2", - "@testing-library/react": "*", - "@testing-library/react-hooks": "*", - "@types/react": ">=16.8", - "@types/react-dom": ">=16.8", - "react": ">=16.8", - "react-dom": ">=16.8", - "react-test-renderer": ">=16.8", - "typescript": "~5" - } +{ + "name": "event-reduce-tests", + "version": "1.0.0", + "license": "MIT", + "private": true, + "author": "Simon Oxtoby", + "homepage": "https://github.com/soxtoby/event-reduce", + "repository": { + "type": "git", + "url": "https://github.com/soxtoby/event-reduce.git" + }, + "dependencies": { + "event-reduce": "workspace:*", + "event-reduce-react": "workspace:*", + "event-reduce-example": "workspace:*" + }, + "devDependencies": { + "@happy-dom/global-registrator": "^20.10.6", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-test-renderer": "^19.2.7", + "typescript": "~6.0.2" + } } \ No newline at end of file diff --git a/tests/setup.ts b/tests/setup.ts index 17bea92..6d51eb2 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,18 +1,3 @@ -import { GlobalRegistrator } from "@happy-dom/global-registrator"; -import { cleanup } from "@testing-library/react"; -import { reactionQueue } from "event-reduce/lib/reactions"; -import { afterEach } from "bun:test"; -import { Action } from "event-reduce/lib/types"; +import { GlobalRegistrator } from "@happy-dom/global-registrator" -GlobalRegistrator.register(); - -reactionQueue.current = { - add(reaction: Action) { - reaction(); - return () => { } - } -} - -afterEach(() => { - cleanup(); -}); \ No newline at end of file +GlobalRegistrator.register() \ No newline at end of file diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 8dd6696..9914ec4 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -1,12 +1,19 @@ -{ - "extends": "../tsconfig.base.json", - "compilerOptions": { - "baseUrl": ".", - "outDir": "./dist", - "rootDir": "." - }, - "include": [ - "./**/*.ts", - "./**/*.tsx" - ] -} \ No newline at end of file +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "." + }, + "references": [ + { + "path": "../packages/event-reduce" + }, + { + "path": "../packages/event-reduce-react" + } + ], + "include": [ + "./**/*.ts", + "./**/*.tsx" + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index bcbcf4f..cdcdd44 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,13 +1,15 @@ { "compilerOptions": { "target": "es2024", - "module": "commonjs", - "lib": ["ES2022", "ESNext.Disposable", "dom"], + "module": "nodenext", + "moduleResolution": "nodenext", + "lib": ["ES2024", "ESNext.Collection", "ESNext.Iterator", "ESNext.Disposable", "dom"], "jsx": "react-jsx", - "strict": true, + "strict": true, "noImplicitOverride": true, - "inlineSourceMap": true, + "sourceMap": true, "inlineSources": true, - "skipLibCheck": true + "skipLibCheck": true, + "types": ["bun"] } } diff --git a/tsconfig.json b/tsconfig.json index 55375bc..4b12590 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ -{ - "extends": "./tsconfig.base.json", - "files": [], - "references": [ - { +{ + "extends": "./tsconfig.base.json", + "files": [], + "references": [ + { "path": "packages/event-reduce" }, { @@ -18,4 +18,4 @@ "path": "tests-legacy" } ] -} \ No newline at end of file +}