Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a66518bde8 | |||
| 034ae56536 | |||
| b417e3d049 | |||
| 2b871402cc | |||
| 24c48d8e9b | |||
| 9ba75f6f98 | |||
| d45e1188b1 | |||
| 9312b8908c | |||
| 2f0b39ae41 | |||
| 575477df09 | |||
| 39aa63bdb3 | |||
| c1aa4eae5e | |||
| d8decdb3e5 | |||
| 03cfee2003 | |||
| f6a3e71f0a | |||
| 6436370abc | |||
| eb1c48bee4 | |||
| 05417ed4c3 | |||
| 1d74a7f465 | |||
| 81ca32cdef | |||
| 07bfbfd393 | |||
| aa411072f2 | |||
| 02575e8baf | |||
| 09fc53aaff | |||
| bcb58dd012 | |||
| f0064bd94b | |||
| e9c527a9dc | |||
| a47d8bb3c7 | |||
| aa6766ef36 | |||
| b0442e1227 | |||
| 0f1eb6eb27 | |||
| dd18ef94bd | |||
| a0189921a5 | |||
| b409f1aa55 | |||
| 57553aa7c8 | |||
| c302cbdae4 | |||
| fd6e3ba8b1 | |||
| 8c3a7aa637 | |||
| 8c0125a8c0 | |||
| 40f3ffbdfb | |||
| d4e01a73d3 | |||
| 641e77892a | |||
| 0c1211778b | |||
| 024f7f4f8f | |||
| 8f1cba5078 | |||
| 188f8057bf | |||
| 99cb86258e | |||
| 83976fa3f4 | |||
| fe81307ca6 |
@@ -6,8 +6,8 @@ on:
|
||||
- '**'
|
||||
|
||||
env:
|
||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@code.foss.global/${{gitea.repository}}.git
|
||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Install pnpm and npmci
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
pnpm install -g @ship.zone/npmci
|
||||
|
||||
- name: Run npm prepare
|
||||
run: npmci npm prepare
|
||||
|
||||
@@ -6,8 +6,8 @@ on:
|
||||
- '*'
|
||||
|
||||
env:
|
||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@code.foss.global/${{gitea.repository}}.git
|
||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
pnpm install -g @ship.zone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Audit production dependencies
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
pnpm install -g @ship.zone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Test stable
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
pnpm install -g @ship.zone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Release
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
pnpm install -g @ship.zone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Code quality
|
||||
|
||||
55
.smartconfig.json
Normal file
55
.smartconfig.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"@git.zone/tsbundle": {
|
||||
"bundles": [
|
||||
{
|
||||
"from": "./ts/index.ts",
|
||||
"to": "./dist_bundle/bundle.js",
|
||||
"outputMode": "bundle",
|
||||
"bundler": "esbuild",
|
||||
"production": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"@git.zone/cli": {
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "push.rocks",
|
||||
"gitrepo": "smartstate",
|
||||
"description": "A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support.",
|
||||
"npmPackagename": "@push.rocks/smartstate",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"state management",
|
||||
"reactive programming",
|
||||
"TypeScript",
|
||||
"observables",
|
||||
"web storage",
|
||||
"state action",
|
||||
"state selection",
|
||||
"state notification",
|
||||
"asynchronous state",
|
||||
"cumulative notification",
|
||||
"middleware",
|
||||
"computed state",
|
||||
"batch updates",
|
||||
"context protocol",
|
||||
"web components",
|
||||
"AbortSignal"
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
},
|
||||
"@git.zone/tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
},
|
||||
"@ship.zone/szci": {
|
||||
"npmGlobalTools": []
|
||||
}
|
||||
}
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["/npmextra.json"],
|
||||
"fileMatch": ["/.smartconfig.json"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
299
changelog.md
Normal file
299
changelog.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-27 - 2.3.0 - feat(stateprocess)
|
||||
add managed state processes with lifecycle controls, scheduled actions, and disposal safety
|
||||
|
||||
- introduces StateProcess with start, pause, resume, dispose, status, and auto-pause support
|
||||
- adds createProcess() and createScheduledAction() on StatePart for polling, streams, and recurring actions
|
||||
- adds disposal guards and Smartstate.dispose() to clean up state parts and attached processes
|
||||
- improves selector and computed observables with distinct-until-changed behavior and skipped selector error emissions
|
||||
- renames npmextra.json to .smartconfig.json and updates package tooling dependencies
|
||||
|
||||
## 2026-03-04 - 2.2.1 - fix(smartstate)
|
||||
no changes detected; no version bump required
|
||||
|
||||
- Git diff shows no changes
|
||||
- package.json version is 2.2.0
|
||||
- No files modified — no release needed
|
||||
|
||||
## 2026-03-02 - 2.2.0 - feat(actions)
|
||||
add action context for safe nested dispatch with depth limit to prevent deadlocks
|
||||
|
||||
- Introduce IActionContext to allow actions to dispatch sub-actions inline via context.dispatch
|
||||
- Update IActionDef signature to accept an optional context parameter for backward compatibility
|
||||
- Add StatePart.createActionContext and MAX_NESTED_DISPATCH_DEPTH to track and limit nested dispatch depth (throws on circular dispatchs)
|
||||
- Pass a created context into dispatchAction so actionDefs can safely perform nested dispatches without deadlocking the mutation queue
|
||||
- Add tests covering re-entrancy, deeply nested dispatch, circular dispatch depth detection, backward compatibility with actions that omit context, and concurrent dispatch serialization
|
||||
|
||||
## 2026-02-28 - 2.1.1 - fix(core)
|
||||
serialize state mutations, fix batch flushing/reentrancy, handle falsy initial values, dispose old StatePart on force, and improve notification/error handling
|
||||
|
||||
- Serialize setState() and dispatchAction() using an internal mutation queue to prevent lost updates and race conditions.
|
||||
- Prevent batch flush deadlocks by introducing isFlushing and draining pending notifications iteratively.
|
||||
- Force initMode now disposes the previous StatePart so the Subject completes and resources are cleaned up.
|
||||
- Treat falsy but non-null values (0, false) as present: getStatePart accepts 0 as initial value and waitUntilPresent resolves for false/0.
|
||||
- Improve notifyChange: use a stable snapshot, catch and log hash computation errors, and avoid duplicate notifications; notifyChangeCumulative now safely catches async errors.
|
||||
- Add StatePart.dispose() to complete the Subject and clear pending timers/middlewares.
|
||||
- Add/adjust tests for concurrent dispatches, concurrent setState, disposal behavior, falsy state handling, batch re-entrancy, force-mode disposal, and zero initialization.
|
||||
- Documentation and README improvements (examples, clearer descriptions, persistence notes) and minor code cleanup (remove unused import).
|
||||
|
||||
## 2026-02-27 - 2.1.0 - feat(smartstate)
|
||||
Add middleware, computed, batching, selector memoization, AbortSignal support, and Web Component Context Protocol provider
|
||||
|
||||
- Introduce StatePart middleware API (addMiddleware) — middleware runs sequentially before validation/persistence and can transform or reject a state change.
|
||||
- Add computed derived observables: standalone computed(sources, fn) and Smartstate.computed to derive values from multiple state parts (lazy subscription).
|
||||
- Add batching support via Smartstate.batch(fn), isBatching flag, and deferred notifications to batch multiple updates and flush only at the outermost level.
|
||||
- Enhance select() with selector memoization (WeakMap cache and shareReplay) and optional AbortSignal support (auto-unsubscribe).
|
||||
- Extend waitUntilPresent() to accept timeout and AbortSignal options and maintain backward-compatible numeric timeout argument.
|
||||
- Add attachContextProvider(element, options) to bridge state parts to Web Component Context Protocol (context-request events) with subscribe/unsubscribe handling.
|
||||
- Update StatePart.setState to run middleware, persist processed state atomically, and defer notifications to batching when applicable.
|
||||
- Tests and README updated to document new features, behaviors, and examples.
|
||||
|
||||
## 2026-02-27 - 2.0.31 - fix(deps)
|
||||
bump devDependencies and fix README license path
|
||||
|
||||
- Bump @git.zone/tsbundle from ^2.8.3 to ^2.9.0
|
||||
- Bump @types/node from ^25.2.0 to ^25.3.2
|
||||
- Update documented dependency set/version to v2.0.30 in readme.hints.md
|
||||
- Fix README license file path from LICENSE to license in readme.md
|
||||
|
||||
## 2026-02-02 - 2.0.30 - fix(config)
|
||||
update npmextra configuration and improve README: rename package keys, add release registry config, clarify waitUntilPresent timeout and notification/persistence behavior
|
||||
|
||||
- Renamed npmextra keys: 'gitzone' → '@git.zone/cli' and 'tsdoc' → '@git.zone/tsdoc'
|
||||
- Added release configuration for @git.zone/cli including registries (verdaccio and npm) and accessLevel
|
||||
- Removed top-level 'npmci' section
|
||||
- Added new '@ship.zone/szci' entry with npmGlobalTools
|
||||
- README: added waitUntilPresent timeout example with error handling
|
||||
- README: clarified notifyChangeCumulative is debounced and documented persistence behavior (merge with defaults, atomic writes)
|
||||
- README: documented concurrency/race-condition safety and timeout support for waitUntilPresent
|
||||
|
||||
## 2026-02-02 - 2.0.29 - fix(smartstate)
|
||||
prevent duplicate statepart creation and fix persistence/notification race conditions
|
||||
|
||||
- Add pendingStatePartCreation map to deduplicate concurrent createStatePart calls
|
||||
- Adjust init handling so 'force' falls through to creation and concurrent creations are serialized
|
||||
- Merge persisted state with initial payload in 'persistent' initMode, with persisted values taking precedence
|
||||
- Persist to WebStore before updating in-memory state to ensure atomicity
|
||||
- Debounce cumulative notifications via pendingCumulativeNotification to avoid duplicate notifications
|
||||
- Log selector errors instead of silently swallowing exceptions
|
||||
- Add optional timeout to waitUntilPresent and ensure subscriptions and timeouts are cleaned up to avoid indefinite waits
|
||||
- Await setState when performing chained state updates to ensure ordering and avoid race conditions
|
||||
|
||||
## 2026-02-02 - 2.0.28 - fix(deps)
|
||||
bump devDependencies and dependencies, add tsbundle build config, update docs, and reorganize tests
|
||||
|
||||
- Bumped @git.zone/tsbuild to ^4.1.2, @git.zone/tsbundle to ^2.8.3, @git.zone/tsrun to ^2.0.1, @git.zone/tstest to ^3.1.8, and @types/node to ^25.2.0
|
||||
- Upgraded @push.rocks/smartjson to ^6.0.0
|
||||
- Added @git.zone/tsbundle bundle configuration to npmextra.json for building a dist bundle
|
||||
- Removed pnpm-workspace.yaml entries (cleaned workspace constraints)
|
||||
- Updated readme and readme.hints (docs formatting, version bumped to v2.0.28, issue reporting/security section and dependency list)
|
||||
- Reorganized tests: removed *.both.ts variants and added consolidated test files under test/ (test.ts, test.initialization.ts)
|
||||
|
||||
## 2025-09-12 - 2.0.27 - fix(StatePart)
|
||||
Use stable JSON stringify for state hashing; update dependencies and tooling
|
||||
|
||||
- Replace smartjson.stringify with smartjson.stableOneWayStringify when creating SHA256 state hashes to ensure deterministic hashing and avoid duplicate notifications for semantically identical states.
|
||||
- Bump runtime dependencies: @push.rocks/smarthash -> ^3.2.6, @push.rocks/smartjson -> ^5.2.0.
|
||||
- Bump dev tooling versions: @git.zone/tsbuild -> ^2.6.8, @git.zone/tsbundle -> ^2.5.1, @git.zone/tstest -> ^2.3.8.
|
||||
- Add local .claude/settings.local.json configuration for allowed permissions (local tooling/settings file).
|
||||
|
||||
## 2025-08-16 - 2.0.26 - fix(ci)
|
||||
Add local Claude settings file to allow helper permissions for common local commands
|
||||
|
||||
- Added .claude/settings.local.json to grant local helper permissions for tooling
|
||||
- Allowed commands: Bash(tsx:*), Bash(tstest test:*), Bash(git add:*), Bash(git tag:*)
|
||||
- No changes to source code or runtime behavior; tooling/config only
|
||||
|
||||
## 2025-07-29 - 2.0.25 - fix(core)
|
||||
Major state initialization and validation improvements
|
||||
|
||||
- Fixed state hash bug: Now properly compares hash values instead of storing state objects
|
||||
- Fixed state initialization merge order: Initial state now correctly takes precedence over stored state
|
||||
- Improved type safety: stateStore properly typed as potentially undefined
|
||||
- Simplified init mode logic with clear behavior for 'soft', 'mandatory', 'force', and 'persistent'
|
||||
- Added state validation with extensible validateState() method
|
||||
- Made notifyChange() async to support proper hash comparison
|
||||
- Enhanced select() to filter undefined states automatically
|
||||
- Added comprehensive test suite for state initialization scenarios
|
||||
- Updated documentation with clearer examples and improved readme
|
||||
|
||||
## 2025-07-19 - 2.0.24 - fix(core)
|
||||
Multiple fixes and improvements
|
||||
|
||||
- Fixed StateAction trigger method to properly return Promise<TStateType>
|
||||
- Updated CI workflows to use new container registry and npmci package name
|
||||
- Added pnpm workspace configuration for built-only dependencies
|
||||
|
||||
## 2025-07-19 - 2.0.23 - fix(ci)
|
||||
Update CI workflows to use new container registry and npmci package name
|
||||
|
||||
- Changed CI image from 'registry.gitlab.com/hosttoday/ht-docker-node:npmci' to 'code.foss.global/host.today/ht-docker-node:npmci'
|
||||
- Replaced npmci installation command from '@shipzone/npmci' to '@ship.zone/npmci' in workflow configurations
|
||||
|
||||
## 2025-07-19 - 2.0.22 - fix(smartstate)
|
||||
Fix StateAction trigger method to properly return Promise
|
||||
|
||||
- Fixed StateAction.trigger() to return Promise<TStateType> as expected
|
||||
- Updated readme with improved documentation and examples
|
||||
- Replaced outdated legal information with Task Venture Capital GmbH details
|
||||
- Added implementation notes in readme.hints.md
|
||||
|
||||
## 2025-06-19 - 2.0.21 - maintenance
|
||||
General updates and improvements
|
||||
|
||||
## 2025-06-19 - 2.0.20 - fix(smartstate)
|
||||
Update build scripts and dependency versions; replace isohash with smarthashWeb for state hash generation
|
||||
|
||||
- Adjusted package.json scripts to include verbose testing and modified build command
|
||||
- Bumped development dependencies (tsbuild, tsbundle, tsrun, tstest, tapbundle) to newer versions
|
||||
- Updated production dependencies (lik, smarthash, smartpromise, smartrx) with minor version bumps
|
||||
- Replaced import of isohash with smarthashWeb in state hash generation, ensuring consistency across modules
|
||||
|
||||
## 2024-10-02 - 2.0.19 - fix(dependencies)
|
||||
Update dependencies to latest versions
|
||||
|
||||
- Updated @git.zone/tsbuild to version ^2.1.84
|
||||
- Updated @git.zone/tsbundle to version ^2.0.15
|
||||
- Updated @git.zone/tsrun to version ^1.2.49
|
||||
- Updated @git.zone/tstest to version ^1.0.90
|
||||
- Updated @push.rocks/tapbundle to version ^5.3.0
|
||||
- Updated @types/node to version ^22.7.4
|
||||
- Updated @push.rocks/lik to version ^6.0.15
|
||||
- Updated @push.rocks/smartjson to version ^5.0.20
|
||||
- Updated @push.rocks/smartpromise to version ^4.0.4
|
||||
- Updated @push.rocks/smartrx to version ^3.0.7
|
||||
- Updated @push.rocks/webstore to version ^2.0.20
|
||||
|
||||
## 2024-10-02 - 2.0.18 - fix(core)
|
||||
Fix type errors and typos in Smartstate class
|
||||
|
||||
- Updated type annotation in Smartstate class to ensure StatePartNameType extends string.
|
||||
- Fixed a typo in the JSDoc comment: 'existing' instead of 'exiting'.
|
||||
- Corrected improper type casting in the Smartstate class.
|
||||
|
||||
## 2024-05-29 - 2.0.17 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Updated project description
|
||||
- Multiple updates to `tsconfig`
|
||||
- Updated `npmextra.json` to include `githost`
|
||||
|
||||
## 2023-10-07 - 2.0.16 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Core update
|
||||
|
||||
## 2023-10-04 - 2.0.15 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Core update
|
||||
|
||||
## 2023-10-03 - 2.0.14 to 2.0.10 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Core updates
|
||||
|
||||
## 2023-09-11 - 2.0.9 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Core update
|
||||
|
||||
## 2023-09-11 - 2.0.8 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Core update
|
||||
|
||||
## 2023-07-27 - 2.0.7 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Core update
|
||||
|
||||
## 2023-07-27 - 2.0.6 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Core update
|
||||
|
||||
## 2023-04-13 - 2.0.5 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Core update
|
||||
|
||||
## 2023-04-12 - 2.0.4 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Core update
|
||||
|
||||
## 2023-04-04 - 2.0.3 to 2.0.1 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Core updates
|
||||
|
||||
## 2023-03-15 - 2.0.0 - Major Update
|
||||
Core update with significant changes.
|
||||
|
||||
## 2022-03-25 - 1.0.23 - Major Update
|
||||
Breaking changes and major updates.
|
||||
|
||||
- SWITCH TO ESM
|
||||
|
||||
## 2022-01-24 - 1.0.22 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Core updates
|
||||
|
||||
## 2020-11-30 - 1.0.21 to 1.0.20 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Core updates
|
||||
|
||||
## 2020-11-30 - 1.0.19 to 1.0.18 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Core updates
|
||||
|
||||
## 2020-07-27 - 1.0.17 to 1.0.16 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Core updates
|
||||
|
||||
## 2020-05-27 - 1.0.15 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Core update
|
||||
|
||||
## 2020-05-27 - 1.0.14 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Core update
|
||||
|
||||
## 2019-09-25 - 1.0.13 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Core update
|
||||
|
||||
## 2019-09-25 - 1.0.12 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Core updates
|
||||
|
||||
## 2019-04-30 - 1.0.11 to 1.0.10 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Core updates
|
||||
|
||||
## 2019-03-22 - 1.0.9 - Maintenance
|
||||
General updates and improvements.
|
||||
|
||||
- Core update
|
||||
|
||||
## 2019-02-27 - 1.0.8 - Minor Update
|
||||
Minor updates and improvements.
|
||||
|
||||
- Updated action generation
|
||||
- Core update
|
||||
|
||||
## 2019-02-21 - 1.0.7 - Initial Release
|
||||
Initial release of the project.
|
||||
|
||||
- Initial core implementation
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"npmci": {
|
||||
"npmGlobalTools": [],
|
||||
"npmAccessLevel": "public"
|
||||
},
|
||||
"gitzone": {
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "gitlab.com",
|
||||
"gitscope": "push.rocks",
|
||||
"gitrepo": "smartstate",
|
||||
"description": "a package that handles state in a good way",
|
||||
"npmPackagename": "@push.rocks/smartstate",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
16523
package-lock.json
generated
Normal file
16523
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
59
package.json
59
package.json
@@ -1,33 +1,32 @@
|
||||
{
|
||||
"name": "@push.rocks/smartstate",
|
||||
"version": "2.0.13",
|
||||
"version": "2.3.0",
|
||||
"private": false,
|
||||
"description": "a package that handles state in a good way",
|
||||
"description": "A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"type": "module",
|
||||
"author": "Lossless GmbH",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "(tstest test/)",
|
||||
"build": "(tsbuild --web --allowimplicitany && tsbundle npm)",
|
||||
"test": "(tstest test/ --verbose)",
|
||||
"build": "(tsbuild tsfolders --allowimplicitany && tsbundle npm)",
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.1.70",
|
||||
"@git.zone/tsbundle": "^2.0.8",
|
||||
"@git.zone/tsrun": "^1.2.46",
|
||||
"@git.zone/tstest": "^1.0.81",
|
||||
"@push.rocks/tapbundle": "^5.0.15",
|
||||
"@types/node": "^20.8.0"
|
||||
"@git.zone/tsbuild": "^4.4.0",
|
||||
"@git.zone/tsbundle": "^2.10.0",
|
||||
"@git.zone/tsrun": "^2.0.2",
|
||||
"@git.zone/tstest": "^3.6.1",
|
||||
"@push.rocks/tapbundle": "^6.0.3",
|
||||
"@types/node": "^25.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/isohash": "^2.0.1",
|
||||
"@push.rocks/lik": "^6.0.5",
|
||||
"@push.rocks/smartjson": "^5.0.10",
|
||||
"@push.rocks/smartpromise": "^4.0.3",
|
||||
"@push.rocks/smartrx": "^3.0.6",
|
||||
"@push.rocks/webstore": "^2.0.13"
|
||||
"@push.rocks/smarthash": "^3.2.6",
|
||||
"@push.rocks/smartjson": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/webstore": "^2.0.21"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
@@ -38,10 +37,34 @@
|
||||
"dist_ts_web/**/*",
|
||||
"assets/**/*",
|
||||
"cli.js",
|
||||
"npmextra.json",
|
||||
".smartconfig.json",
|
||||
"readme.md"
|
||||
],
|
||||
"browserslist": [
|
||||
"last 1 chrome versions"
|
||||
]
|
||||
],
|
||||
"keywords": [
|
||||
"state management",
|
||||
"reactive programming",
|
||||
"TypeScript",
|
||||
"observables",
|
||||
"web storage",
|
||||
"state action",
|
||||
"state selection",
|
||||
"state notification",
|
||||
"asynchronous state",
|
||||
"cumulative notification",
|
||||
"middleware",
|
||||
"computed state",
|
||||
"batch updates",
|
||||
"context protocol",
|
||||
"web components",
|
||||
"AbortSignal"
|
||||
],
|
||||
"homepage": "https://code.foss.global/push.rocks/smartstate",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://code.foss.global/push.rocks/smartstate.git"
|
||||
},
|
||||
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
|
||||
}
|
||||
|
||||
13393
pnpm-lock.yaml
generated
13393
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
77
readme.hints.md
Normal file
77
readme.hints.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Smartstate Implementation Notes
|
||||
|
||||
## Current API (as of v2.0.31)
|
||||
|
||||
### State Part Initialization
|
||||
- State parts can be created with different init modes: 'soft' (default), 'mandatory', 'force', 'persistent'
|
||||
- 'soft' - returns existing state part if exists, creates new if not
|
||||
- 'mandatory' - requires state part to not exist, fails if it does
|
||||
- 'force' - always creates new state part, overwriting any existing
|
||||
- 'persistent' - like 'soft' but with WebStore persistence (IndexedDB)
|
||||
- Persistent mode automatically calls init() internally
|
||||
- State merge order fixed: initial state takes precedence over stored state
|
||||
|
||||
### Actions
|
||||
- Actions are created with `createAction()` method
|
||||
- Two ways to dispatch: `stateAction.trigger(payload)` or `statePart.dispatchAction(stateAction, payload)`
|
||||
- Both return Promise<TStatePayload>
|
||||
|
||||
### State Management Methods
|
||||
- `select(fn?, { signal? })` - returns Observable, memoized by selector fn ref, supports AbortSignal
|
||||
- `waitUntilPresent(fn?, number | { timeoutMs?, signal? })` - waits for state condition, backward compat with number arg
|
||||
- `stateSetup()` - async state initialization with cumulative defer
|
||||
- `notifyChangeCumulative()` - defers notification to end of call stack
|
||||
- `getState()` - returns current state or undefined
|
||||
- `setState()` - runs middleware, validates, persists, notifies
|
||||
- `addMiddleware(fn)` - intercepts setState, returns removal function
|
||||
|
||||
### Middleware
|
||||
- Type: `(newState, oldState) => newState | Promise<newState>`
|
||||
- Runs sequentially in insertion order before validation/persistence
|
||||
- Throw to reject state changes (atomic — state unchanged on error)
|
||||
- Does NOT run during initial createStatePart() hydration
|
||||
|
||||
### Selector Memoization
|
||||
- Uses WeakMap<Function, Observable> for fn-keyed cache
|
||||
- `defaultSelectObservable` for no-arg select()
|
||||
- Wrapped in `shareReplay({ bufferSize: 1, refCount: true })`
|
||||
- NOT cached when AbortSignal is provided
|
||||
|
||||
### Batch Updates
|
||||
- `smartstate.batch(async () => {...})` — defers notifications until batch completes
|
||||
- Supports nesting — only flushes at outermost level
|
||||
- StatePart has `smartstateRef` set by `createStatePart()` for batch awareness
|
||||
- State parts created via `new StatePart()` directly work without batching
|
||||
|
||||
### Computed State
|
||||
- `computed(sources, fn)` — standalone function using `combineLatest` + `map`
|
||||
- Also available as `smartstate.computed(sources, fn)`
|
||||
- Lazy — only subscribes when subscribed to
|
||||
|
||||
### Context Protocol Bridge
|
||||
- `attachContextProvider(element, { context, statePart, selectorFn? })` — returns cleanup fn
|
||||
- Listens for `context-request` CustomEvent on element
|
||||
- Supports one-shot and subscription modes
|
||||
- Works with Lit @consume(), FAST, or any Context Protocol consumer
|
||||
|
||||
### State Hash Detection
|
||||
- Uses SHA256 hash to detect actual state changes
|
||||
- Hash comparison properly awaits async hash calculation
|
||||
- Prevents duplicate notifications for identical state values
|
||||
|
||||
### State Validation
|
||||
- Basic validation ensures state is not null/undefined
|
||||
- `validateState()` can be overridden in subclasses
|
||||
|
||||
### Key Notes
|
||||
- `smartstateRef` creates circular ref between StatePart and Smartstate
|
||||
- Use `===` not deep equality for StatePart comparison in tests
|
||||
- Direct rxjs imports used for: Observable, shareReplay, takeUntil, combineLatest, map
|
||||
|
||||
## Dependency Versions (v2.0.31)
|
||||
- @git.zone/tsbuild: ^4.1.2
|
||||
- @git.zone/tsbundle: ^2.9.0
|
||||
- @git.zone/tsrun: ^2.0.1
|
||||
- @git.zone/tstest: ^3.1.8
|
||||
- @push.rocks/smartjson: ^6.0.0
|
||||
- @types/node: ^25.3.2
|
||||
611
readme.md
611
readme.md
@@ -1,37 +1,596 @@
|
||||
# @push.rocks/smartstate
|
||||
a package that handles state in a good way
|
||||
|
||||
## Availabililty and Links
|
||||
* [npmjs.org (npm package)](https://www.npmjs.com/package/@push.rocks/smartstate)
|
||||
* [gitlab.com (source)](https://gitlab.com/push.rocks/smartstate)
|
||||
* [github.com (source mirror)](https://github.com/push.rocks/smartstate)
|
||||
* [docs (typedoc)](https://push.rocks.gitlab.io/smartstate/)
|
||||
A TypeScript-first reactive state management library with processes, middleware, computed state, batching, persistence, and Web Component Context Protocol support 🚀
|
||||
|
||||
## Status for master
|
||||
## Issue Reporting and Security
|
||||
|
||||
Status Category | Status Badge
|
||||
-- | --
|
||||
GitLab Pipelines | [](https://lossless.cloud)
|
||||
GitLab Pipline Test Coverage | [](https://lossless.cloud)
|
||||
npm | [](https://lossless.cloud)
|
||||
Snyk | [](https://lossless.cloud)
|
||||
TypeScript Support | [](https://lossless.cloud)
|
||||
node Support | [](https://nodejs.org/dist/latest-v10.x/docs/api/)
|
||||
Code Style | [](https://lossless.cloud)
|
||||
PackagePhobia (total standalone install weight) | [](https://lossless.cloud)
|
||||
PackagePhobia (package size on registry) | [](https://lossless.cloud)
|
||||
BundlePhobia (total size when bundled) | [](https://lossless.cloud)
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install @push.rocks/smartstate --save
|
||||
```
|
||||
|
||||
Or with npm:
|
||||
|
||||
```bash
|
||||
npm install @push.rocks/smartstate --save
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Use TypeScript for best in class intellisense.
|
||||
### Quick Start
|
||||
|
||||
## Contribution
|
||||
```typescript
|
||||
import { Smartstate } from '@push.rocks/smartstate';
|
||||
|
||||
We are always happy for code contributions. If you are not the code contributing type that is ok. Still, maintaining Open Source repositories takes considerable time and thought. If you like the quality of what we do and our modules are useful to you we would appreciate a little monthly contribution: You can [contribute one time](https://lossless.link/contribute-onetime) or [contribute monthly](https://lossless.link/contribute). :)
|
||||
// 1. Define your state part names
|
||||
type AppParts = 'user' | 'settings';
|
||||
|
||||
For further information read the linked docs at the top of this readme.
|
||||
// 2. Create the root instance
|
||||
const state = new Smartstate<AppParts>();
|
||||
|
||||
## Legal
|
||||
> MIT licensed | **©** [Task Venture Capital GmbH](https://task.vc)
|
||||
| By using this npm module you agree to our [privacy policy](https://lossless.gmbH/privacy)
|
||||
// 3. Create state parts with initial values
|
||||
const userState = await state.getStatePart<{ name: string; loggedIn: boolean }>('user', {
|
||||
name: '',
|
||||
loggedIn: false,
|
||||
});
|
||||
|
||||
// 4. Subscribe to changes
|
||||
userState.select((s) => s.name).subscribe((name) => {
|
||||
console.log('Name changed:', name);
|
||||
});
|
||||
|
||||
// 5. Update state
|
||||
await userState.setState({ name: 'Alice', loggedIn: true });
|
||||
```
|
||||
|
||||
### 🧩 State Parts & Init Modes
|
||||
|
||||
State parts are isolated, typed units of state — the building blocks of your application's state tree. Create them via `getStatePart()`:
|
||||
|
||||
```typescript
|
||||
const part = await state.getStatePart<IMyState>(name, initialState, initMode);
|
||||
```
|
||||
|
||||
| Init Mode | Behavior |
|
||||
|-----------|----------|
|
||||
| `'soft'` (default) | Returns existing if found, creates new otherwise |
|
||||
| `'mandatory'` | Throws if state part already exists — useful for ensuring single-initialization |
|
||||
| `'force'` | Always creates a new state part, disposing and overwriting any existing one |
|
||||
| `'persistent'` | Like `'soft'` but automatically persists state to IndexedDB via WebStore |
|
||||
|
||||
You can use either string literal union types or enums for state part names:
|
||||
|
||||
```typescript
|
||||
// String literal types (simpler)
|
||||
type AppParts = 'user' | 'settings' | 'cart';
|
||||
|
||||
// Enums (more explicit)
|
||||
enum AppParts {
|
||||
User = 'user',
|
||||
Settings = 'settings',
|
||||
Cart = 'cart',
|
||||
}
|
||||
```
|
||||
|
||||
#### 💾 Persistent State
|
||||
|
||||
```typescript
|
||||
const settings = await state.getStatePart('settings', { theme: 'dark', fontSize: 14 }, 'persistent');
|
||||
|
||||
// ✅ Automatically saved to IndexedDB on every setState()
|
||||
// ✅ On next app load, persisted values override defaults
|
||||
// ✅ Persistence writes complete before in-memory updates
|
||||
```
|
||||
|
||||
### 🔭 Selecting State
|
||||
|
||||
`select()` returns an RxJS Observable that emits the current value immediately (via `BehaviorSubject`) and on every subsequent change:
|
||||
|
||||
```typescript
|
||||
// Full state
|
||||
userState.select().subscribe((state) => console.log(state));
|
||||
|
||||
// Derived value via selector function
|
||||
userState.select((s) => s.name).subscribe((name) => console.log(name));
|
||||
```
|
||||
|
||||
Selectors are **memoized** — calling `select(fn)` with the same function reference returns the same cached Observable, shared across all subscribers via `shareReplay`. This means you can call `select(mySelector)` in multiple places without creating duplicate subscriptions.
|
||||
|
||||
**Change detection** is built in: `select()` uses `distinctUntilChanged` with deep JSON comparison, so subscribers only fire when the selected value actually changes. Selecting `s => s.name` won't re-emit when only `s.count` changes.
|
||||
|
||||
#### ✂️ AbortSignal Support
|
||||
|
||||
Clean up subscriptions without manual `.unsubscribe()` — the modern way:
|
||||
|
||||
```typescript
|
||||
const controller = new AbortController();
|
||||
|
||||
userState.select((s) => s.name, { signal: controller.signal }).subscribe((name) => {
|
||||
console.log(name); // automatically stops receiving when aborted
|
||||
});
|
||||
|
||||
// Later: clean up all subscriptions tied to this signal
|
||||
controller.abort();
|
||||
```
|
||||
|
||||
### ⚡ Actions
|
||||
|
||||
Actions provide controlled, named state mutations with full async support:
|
||||
|
||||
```typescript
|
||||
interface ILoginPayload {
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const loginAction = userState.createAction<ILoginPayload>(async (statePart, payload) => {
|
||||
const current = statePart.getState();
|
||||
return { ...current, name: payload.username, loggedIn: true };
|
||||
});
|
||||
|
||||
// Two equivalent ways to dispatch:
|
||||
await loginAction.trigger({ username: 'Alice', email: 'alice@example.com' });
|
||||
// or
|
||||
await userState.dispatchAction(loginAction, { username: 'Alice', email: 'alice@example.com' });
|
||||
```
|
||||
|
||||
Both `trigger()` and `dispatchAction()` return a Promise with the new state. All dispatches are serialized through a mutation queue, so concurrent dispatches never cause lost updates.
|
||||
|
||||
#### 🔗 Nested Actions (Action Context)
|
||||
|
||||
When you need to dispatch sub-actions from within an action, use the `context` parameter. This is critical because calling `dispatchAction()` directly from inside an action would deadlock (it tries to acquire the mutation queue that's already held). The context's `dispatch()` bypasses the queue and executes inline:
|
||||
|
||||
```typescript
|
||||
const incrementAction = userState.createAction<number>(async (statePart, amount) => {
|
||||
const current = statePart.getState();
|
||||
return { ...current, count: current.count + amount };
|
||||
});
|
||||
|
||||
const doubleIncrementAction = userState.createAction<number>(async (statePart, amount, context) => {
|
||||
// ✅ Safe: uses context.dispatch() which bypasses the mutation queue
|
||||
await context.dispatch(incrementAction, amount);
|
||||
const current = statePart.getState();
|
||||
return { ...current, count: current.count + amount };
|
||||
});
|
||||
|
||||
// ❌ DON'T do this inside an action — it will deadlock:
|
||||
// await statePart.dispatchAction(someAction, payload);
|
||||
```
|
||||
|
||||
A built-in depth limit (10 levels) prevents infinite circular dispatch chains, throwing a clear error if exceeded.
|
||||
|
||||
### 🔄 Processes (Polling, Streams & Scheduled Tasks)
|
||||
|
||||
Processes are managed, pausable observable-to-state bridges — the "side effects" layer. They tie an ongoing data source (polling, WebSockets, event streams) to state updates with full lifecycle control and optional auto-pause.
|
||||
|
||||
#### Basic Process: Polling an API
|
||||
|
||||
```typescript
|
||||
import { interval, switchMap, from } from 'rxjs';
|
||||
|
||||
const metricsPoller = dashboard.createProcess<{ cpu: number; memory: number }>({
|
||||
// Producer: an Observable factory — called on start and each resume
|
||||
producer: () => interval(5000).pipe(
|
||||
switchMap(() => from(fetch('/api/metrics').then(r => r.json()))),
|
||||
),
|
||||
// Reducer: folds each produced value into state (runs through middleware & validation)
|
||||
reducer: (currentState, metrics) => ({
|
||||
...currentState,
|
||||
metrics,
|
||||
lastUpdated: Date.now(),
|
||||
}),
|
||||
autoPause: 'visibility', // ⏸️ Stop polling when the tab is hidden
|
||||
autoStart: true, // ▶️ Start immediately
|
||||
});
|
||||
|
||||
// Full lifecycle control
|
||||
metricsPoller.pause(); // Unsubscribes from producer
|
||||
metricsPoller.resume(); // Re-subscribes (fresh subscription)
|
||||
metricsPoller.dispose(); // Permanent cleanup
|
||||
|
||||
// Observe status reactively
|
||||
metricsPoller.status; // 'idle' | 'running' | 'paused' | 'disposed'
|
||||
metricsPoller.status$.subscribe(s => console.log('Process:', s));
|
||||
```
|
||||
|
||||
#### Scheduled Actions
|
||||
|
||||
Dispatch an existing action on a recurring interval — syntactic sugar over `createProcess`:
|
||||
|
||||
```typescript
|
||||
const refreshAction = dashboard.createAction<void>(async (sp) => {
|
||||
const data = await fetch('/api/dashboard').then(r => r.json());
|
||||
return { ...sp.getState()!, ...data, lastUpdated: Date.now() };
|
||||
});
|
||||
|
||||
// Dispatches refreshAction every 30 seconds, auto-pauses when tab is hidden
|
||||
const scheduled = dashboard.createScheduledAction({
|
||||
action: refreshAction,
|
||||
payload: undefined,
|
||||
intervalMs: 30000,
|
||||
autoPause: 'visibility',
|
||||
});
|
||||
|
||||
// It's a full StateProcess — pause, resume, dispose all work
|
||||
scheduled.dispose();
|
||||
```
|
||||
|
||||
#### Custom Auto-Pause Signals
|
||||
|
||||
Pass any `Observable<boolean>` as the auto-pause signal — `true` means active, `false` means pause:
|
||||
|
||||
```typescript
|
||||
import { fromEvent, map, startWith } from 'rxjs';
|
||||
|
||||
// Pause when offline, resume when online
|
||||
const onlineSignal = fromEvent(window, 'online').pipe(
|
||||
startWith(null),
|
||||
map(() => navigator.onLine),
|
||||
);
|
||||
|
||||
const syncProcess = userPart.createProcess<SyncPayload>({
|
||||
producer: () => interval(10000).pipe(
|
||||
switchMap(() => from(syncWithServer())),
|
||||
),
|
||||
reducer: (state, result) => ({ ...state, ...result }),
|
||||
autoPause: onlineSignal,
|
||||
});
|
||||
syncProcess.start();
|
||||
```
|
||||
|
||||
#### WebSocket / Live Streams
|
||||
|
||||
Pause disconnects; resume creates a fresh connection:
|
||||
|
||||
```typescript
|
||||
const liveProcess = tickerPart.createProcess<TradeEvent>({
|
||||
producer: () => new Observable<TradeEvent>(subscriber => {
|
||||
const ws = new WebSocket('wss://trades.example.com');
|
||||
ws.onmessage = (e) => subscriber.next(JSON.parse(e.data));
|
||||
ws.onerror = (e) => subscriber.error(e);
|
||||
ws.onclose = () => subscriber.complete();
|
||||
return () => ws.close(); // Teardown: close WebSocket on unsubscribe
|
||||
}),
|
||||
reducer: (state, trade) => ({
|
||||
...state,
|
||||
lastPrice: trade.price,
|
||||
trades: [...state.trades.slice(-99), trade],
|
||||
}),
|
||||
autoPause: 'visibility',
|
||||
});
|
||||
liveProcess.start();
|
||||
```
|
||||
|
||||
#### Error Recovery
|
||||
|
||||
If a producer errors, the process gracefully transitions to `'paused'` instead of dying. Call `resume()` to retry with a fresh subscription:
|
||||
|
||||
```typescript
|
||||
process.start();
|
||||
// Producer errors → status becomes 'paused'
|
||||
process.resume(); // Creates a fresh subscription — retry
|
||||
```
|
||||
|
||||
#### Process Cleanup Cascades
|
||||
|
||||
Disposing a `StatePart` or `Smartstate` instance automatically disposes all attached processes:
|
||||
|
||||
```typescript
|
||||
const p1 = part.createProcess({ ... });
|
||||
const p2 = part.createProcess({ ... });
|
||||
p1.start();
|
||||
p2.start();
|
||||
|
||||
part.dispose();
|
||||
console.log(p1.status); // 'disposed'
|
||||
console.log(p2.status); // 'disposed'
|
||||
```
|
||||
|
||||
### 🛡️ Middleware
|
||||
|
||||
Intercept every `setState()` call to transform, validate, log, or reject state changes:
|
||||
|
||||
```typescript
|
||||
// Logging middleware
|
||||
userState.addMiddleware((newState, oldState) => {
|
||||
console.log('State changing:', oldState, '→', newState);
|
||||
return newState;
|
||||
});
|
||||
|
||||
// Validation middleware — throw to reject the change
|
||||
userState.addMiddleware((newState) => {
|
||||
if (!newState.name) throw new Error('Name is required');
|
||||
return newState;
|
||||
});
|
||||
|
||||
// Transform middleware
|
||||
userState.addMiddleware((newState) => {
|
||||
return { ...newState, name: newState.name.trim() };
|
||||
});
|
||||
|
||||
// Async middleware
|
||||
userState.addMiddleware(async (newState, oldState) => {
|
||||
await auditLog('state-change', { from: oldState, to: newState });
|
||||
return newState;
|
||||
});
|
||||
|
||||
// Removal — addMiddleware() returns a dispose function
|
||||
const remove = userState.addMiddleware(myMiddleware);
|
||||
remove(); // middleware no longer runs
|
||||
```
|
||||
|
||||
Middleware runs **sequentially** in insertion order. If any middleware throws, the state remains unchanged — the operation is **atomic**. Process-driven state updates go through middleware too.
|
||||
|
||||
### 🧮 Computed / Derived State
|
||||
|
||||
Derive reactive values from one or more state parts using `combineLatest` under the hood:
|
||||
|
||||
```typescript
|
||||
import { computed } from '@push.rocks/smartstate';
|
||||
|
||||
const userState = await state.getStatePart('user', { firstName: 'Jane', lastName: 'Doe' });
|
||||
const settingsState = await state.getStatePart('settings', { locale: 'en' });
|
||||
|
||||
// Standalone function
|
||||
const greeting$ = computed(
|
||||
[userState, settingsState],
|
||||
(user, settings) => `Hello, ${user.firstName} (${settings.locale})`,
|
||||
);
|
||||
|
||||
greeting$.subscribe((msg) => console.log(msg));
|
||||
// => "Hello, Jane (en)"
|
||||
|
||||
// Also available as a convenience method on the Smartstate instance:
|
||||
const greeting2$ = state.computed(
|
||||
[userState, settingsState],
|
||||
(user, settings) => `${user.firstName} - ${settings.locale}`,
|
||||
);
|
||||
```
|
||||
|
||||
Computed observables are **lazy** — they only subscribe to their sources when someone subscribes to them, and they automatically unsubscribe when all subscribers disconnect. They also use `distinctUntilChanged` to avoid redundant emissions when the derived value hasn't actually changed.
|
||||
|
||||
### 📦 Batch Updates
|
||||
|
||||
Update multiple state parts at once while deferring all notifications until the entire batch completes:
|
||||
|
||||
```typescript
|
||||
const partA = await state.getStatePart('a', { value: 1 });
|
||||
const partB = await state.getStatePart('b', { value: 2 });
|
||||
|
||||
await state.batch(async () => {
|
||||
await partA.setState({ value: 10 });
|
||||
await partB.setState({ value: 20 });
|
||||
// No notifications fire inside the batch
|
||||
});
|
||||
// Both subscribers now fire with their new values simultaneously
|
||||
|
||||
// Nested batches are supported — flush happens at the outermost level only
|
||||
await state.batch(async () => {
|
||||
await partA.setState({ value: 100 });
|
||||
await state.batch(async () => {
|
||||
await partB.setState({ value: 200 });
|
||||
});
|
||||
// Still deferred — inner batch doesn't trigger flush
|
||||
});
|
||||
// Now both fire
|
||||
```
|
||||
|
||||
### ⏳ Waiting for State
|
||||
|
||||
Wait for a specific state condition to be met before proceeding:
|
||||
|
||||
```typescript
|
||||
// Wait for any truthy state
|
||||
const currentState = await userState.waitUntilPresent();
|
||||
|
||||
// Wait for a specific condition
|
||||
const name = await userState.waitUntilPresent((s) => s.name || undefined);
|
||||
|
||||
// With timeout (milliseconds)
|
||||
const name = await userState.waitUntilPresent((s) => s.name || undefined, 5000);
|
||||
|
||||
// With AbortSignal and/or timeout via options object
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const name = await userState.waitUntilPresent(
|
||||
(s) => s.name || undefined,
|
||||
{ timeoutMs: 5000, signal: controller.signal },
|
||||
);
|
||||
} catch (e) {
|
||||
// e.message is 'Aborted' or 'waitUntilPresent timed out after 5000ms'
|
||||
}
|
||||
```
|
||||
|
||||
### 🌐 Context Protocol Bridge (Web Components)
|
||||
|
||||
Expose state parts to web components via the [W3C Context Protocol](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md). This lets any web component framework (Lit, FAST, Stencil, or vanilla) consume your state without coupling:
|
||||
|
||||
```typescript
|
||||
import { attachContextProvider } from '@push.rocks/smartstate';
|
||||
|
||||
// Define a context key (use Symbol for uniqueness)
|
||||
const themeContext = Symbol('theme');
|
||||
|
||||
// Attach a provider to a DOM element — any descendant can consume it
|
||||
const cleanup = attachContextProvider(document.body, {
|
||||
context: themeContext,
|
||||
statePart: settingsState,
|
||||
selectorFn: (s) => s.theme, // optional: provide a derived value instead of full state
|
||||
});
|
||||
|
||||
// A consumer dispatches a context-request event:
|
||||
myComponent.dispatchEvent(
|
||||
new CustomEvent('context-request', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: {
|
||||
context: themeContext,
|
||||
callback: (theme) => console.log('Got theme:', theme),
|
||||
subscribe: true, // receive updates whenever the state changes
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Works seamlessly with Lit's @consume() decorator, FAST's context, etc.
|
||||
|
||||
// Cleanup when the provider is no longer needed
|
||||
cleanup();
|
||||
```
|
||||
|
||||
### ✅ State Validation
|
||||
|
||||
Built-in validation prevents `null` and `undefined` from being set as state. For custom validation, extend `StatePart`:
|
||||
|
||||
```typescript
|
||||
import { StatePart } from '@push.rocks/smartstate';
|
||||
|
||||
class ValidatedUserPart extends StatePart<string, IUserState> {
|
||||
protected validateState(stateArg: any): stateArg is IUserState {
|
||||
return (
|
||||
super.validateState(stateArg) &&
|
||||
typeof stateArg.name === 'string' &&
|
||||
typeof stateArg.loggedIn === 'boolean'
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If validation fails, `setState()` throws and the state remains unchanged.
|
||||
|
||||
### ⚙️ Async State Setup
|
||||
|
||||
Initialize state with async operations while ensuring actions wait for setup to complete:
|
||||
|
||||
```typescript
|
||||
await userState.stateSetup(async (statePart) => {
|
||||
const userData = await fetchUserFromAPI();
|
||||
return { ...statePart.getState(), ...userData };
|
||||
});
|
||||
|
||||
// Any dispatchAction() calls will automatically wait for stateSetup() to finish
|
||||
```
|
||||
|
||||
### 🧹 Disposal & Cleanup
|
||||
|
||||
Both `Smartstate` and individual `StatePart` instances support disposal for proper cleanup:
|
||||
|
||||
```typescript
|
||||
// Dispose a single state part — completes the BehaviorSubject, clears middleware, caches,
|
||||
// and disposes all attached processes
|
||||
userState.dispose();
|
||||
|
||||
// Dispose the entire Smartstate instance — disposes all state parts and clears internal maps
|
||||
state.dispose();
|
||||
```
|
||||
|
||||
After disposal, `setState()` and `dispatchAction()` will throw if called on a disposed `StatePart`. Calling `start()`, `pause()`, or `resume()` on a disposed `StateProcess` also throws.
|
||||
|
||||
### 🏎️ Performance
|
||||
|
||||
Smartstate is built with performance in mind:
|
||||
|
||||
- **🔒 SHA256 Change Detection** — Uses content hashing to detect actual changes. Identical state values don't trigger notifications, even with different object references.
|
||||
- **🎯 distinctUntilChanged on Selectors** — Sub-selectors only fire when the selected slice actually changes. `select(s => s.name)` won't emit when `s.count` changes.
|
||||
- **♻️ Selector Memoization** — `select(fn)` caches observables by function reference and shares them via `shareReplay({ refCount: true })`. Multiple subscribers share one upstream subscription.
|
||||
- **📦 Cumulative Notifications** — `notifyChangeCumulative()` debounces rapid changes into a single notification at the end of the call stack.
|
||||
- **🔐 Concurrent Safety** — Simultaneous `getStatePart()` calls for the same name return the same promise, preventing duplicate creation. All `setState()` and `dispatchAction()` calls are serialized through a mutation queue. Process values are serialized through their own internal queue.
|
||||
- **💾 Atomic Persistence** — WebStore writes complete before in-memory state updates, ensuring consistency.
|
||||
- **⏸️ Batch Deferred Notifications** — `batch()` suppresses all subscriber notifications until every update in the batch completes.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `Smartstate<T>`
|
||||
|
||||
| Method / Property | Description |
|
||||
|-------------------|-------------|
|
||||
| `getStatePart(name, initial?, initMode?)` | Get or create a typed state part |
|
||||
| `batch(fn)` | Batch state updates, defer all notifications until complete |
|
||||
| `computed(sources, fn)` | Create a computed observable from multiple state parts |
|
||||
| `dispose()` | Dispose all state parts and clear internal state |
|
||||
| `isBatching` | `boolean` — whether a batch is currently active |
|
||||
|
||||
### `StatePart<TName, TPayload>`
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `getState()` | Get current state synchronously (`TPayload \| undefined`) |
|
||||
| `setState(newState)` | Set state — runs middleware → validates → persists → notifies |
|
||||
| `select(selectorFn?, options?)` | Observable of state or derived values. Options: `{ signal?: AbortSignal }` |
|
||||
| `createAction(actionDef)` | Create a reusable, typed state action |
|
||||
| `dispatchAction(action, payload)` | Dispatch an action and return the new state |
|
||||
| `addMiddleware(fn)` | Add a middleware interceptor. Returns a removal function |
|
||||
| `waitUntilPresent(selectorFn?, opts?)` | Wait for a state condition. Opts: `number` (timeout) or `{ timeoutMs?, signal? }` |
|
||||
| `createProcess(options)` | Create a managed, pausable process tied to this state part |
|
||||
| `createScheduledAction(options)` | Create a process that dispatches an action on a recurring interval |
|
||||
| `notifyChange()` | Manually trigger a change notification (with hash dedup) |
|
||||
| `notifyChangeCumulative()` | Debounced notification — fires at end of call stack |
|
||||
| `stateSetup(fn)` | Async state initialization with action serialization |
|
||||
| `dispose()` | Complete the BehaviorSubject, dispose processes, clear middleware and caches |
|
||||
|
||||
### `StateAction<TState, TPayload>`
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `trigger(payload)` | Dispatch the action on its associated state part |
|
||||
|
||||
### `StateProcess<TName, TPayload, TProducerValue>`
|
||||
|
||||
| Method / Property | Description |
|
||||
|-------------------|-------------|
|
||||
| `start()` | Start the process (subscribes to producer, sets up auto-pause) |
|
||||
| `pause()` | Pause the process (unsubscribes from producer) |
|
||||
| `resume()` | Resume a paused process (fresh subscription to producer) |
|
||||
| `dispose()` | Permanently stop the process and clean up |
|
||||
| `status` | Current status: `'idle' \| 'running' \| 'paused' \| 'disposed'` |
|
||||
| `status$` | Observable of status transitions |
|
||||
|
||||
### `IActionContext<TState>`
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `dispatch(action, payload)` | Dispatch a sub-action inline (bypasses mutation queue). Available as the third argument to action definitions |
|
||||
|
||||
### Standalone Functions
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `computed(sources, fn)` | Create a computed observable from multiple state parts |
|
||||
| `attachContextProvider(element, options)` | Bridge a state part to the W3C Context Protocol |
|
||||
|
||||
### Exported Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `TInitMode` | `'soft' \| 'mandatory' \| 'force' \| 'persistent'` |
|
||||
| `TMiddleware<TPayload>` | `(newState, oldState) => TPayload \| Promise<TPayload>` |
|
||||
| `IActionDef<TState, TPayload>` | Action definition function signature (receives statePart, payload, context?) |
|
||||
| `IActionContext<TState>` | Context for safe nested dispatch within actions |
|
||||
| `IContextProviderOptions<TPayload>` | Options for `attachContextProvider` |
|
||||
| `IProcessOptions<TPayload, TValue>` | Options for `createProcess` (producer, reducer, autoPause, autoStart) |
|
||||
| `IScheduledActionOptions<TPayload, TActionPayload>` | Options for `createScheduledAction` (action, payload, intervalMs, autoPause) |
|
||||
| `TProcessStatus` | `'idle' \| 'running' \| 'paused' \| 'disposed'` |
|
||||
| `TAutoPause` | `'visibility' \| Observable<boolean> \| false` |
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
|
||||
995
test/test.initialization.ts
Normal file
995
test/test.initialization.ts
Normal file
@@ -0,0 +1,995 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import * as smartstate from '../ts/index.js';
|
||||
|
||||
type TTestStateParts = 'initTest' | 'persistTest' | 'forceTest';
|
||||
|
||||
interface ITestState {
|
||||
value: number;
|
||||
nested: {
|
||||
data: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Init mode tests
|
||||
// ============================
|
||||
|
||||
tap.test('should handle soft init mode (default)', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
|
||||
const statePart1 = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
});
|
||||
expect(statePart1.getState()).toEqual({
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
});
|
||||
|
||||
const statePart2 = await state.getStatePart<ITestState>('initTest');
|
||||
expect(statePart1 === statePart2).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should handle mandatory init mode', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
|
||||
const statePart1 = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
}, 'mandatory');
|
||||
expect(statePart1).toBeInstanceOf(smartstate.StatePart);
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await state.getStatePart<ITestState>('initTest', {
|
||||
value: 2,
|
||||
nested: { data: 'second' }
|
||||
}, 'mandatory');
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toMatch(/already exists.*mandatory/);
|
||||
});
|
||||
|
||||
tap.test('should handle force init mode', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
|
||||
const statePart1 = await state.getStatePart<ITestState>('forceTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
});
|
||||
expect(statePart1.getState()?.value).toEqual(1);
|
||||
|
||||
const statePart2 = await state.getStatePart<ITestState>('forceTest', {
|
||||
value: 2,
|
||||
nested: { data: 'forced' }
|
||||
}, 'force');
|
||||
expect(statePart2.getState()?.value).toEqual(2);
|
||||
expect(statePart1 === statePart2).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('should handle missing initial state error', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await state.getStatePart<ITestState>('initTest');
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toMatch(/does not exist.*no initial state/);
|
||||
});
|
||||
|
||||
tap.test('should handle state validation', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
|
||||
const statePart = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
});
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await statePart.setState(null as any);
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toMatch(/Invalid state structure/);
|
||||
});
|
||||
|
||||
tap.test('should handle undefined state in select', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
const statePart = new smartstate.StatePart<TTestStateParts, ITestState>('initTest');
|
||||
|
||||
const values: (ITestState | undefined)[] = [];
|
||||
statePart.select().subscribe(val => values.push(val));
|
||||
|
||||
expect(values).toHaveLength(0);
|
||||
|
||||
await statePart.setState({
|
||||
value: 1,
|
||||
nested: { data: 'test' }
|
||||
});
|
||||
|
||||
expect(values).toHaveLength(1);
|
||||
expect(values[0]).toEqual({
|
||||
value: 1,
|
||||
nested: { data: 'test' }
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('should not notify on duplicate state', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
const statePart = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
});
|
||||
|
||||
let notificationCount = 0;
|
||||
statePart.select().subscribe(() => notificationCount++);
|
||||
|
||||
expect(notificationCount).toEqual(1);
|
||||
|
||||
await statePart.setState({ value: 1, nested: { data: 'initial' } });
|
||||
await statePart.setState({ value: 1, nested: { data: 'initial' } });
|
||||
await statePart.setState({ value: 1, nested: { data: 'initial' } });
|
||||
|
||||
expect(notificationCount).toEqual(1);
|
||||
|
||||
await statePart.setState({ value: 2, nested: { data: 'changed' } });
|
||||
expect(notificationCount).toEqual(2);
|
||||
});
|
||||
|
||||
// ============================
|
||||
// AbortSignal tests
|
||||
// ============================
|
||||
|
||||
tap.test('select should complete when AbortSignal fires', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
const statePart = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const values: any[] = [];
|
||||
let completed = false;
|
||||
|
||||
statePart.select(undefined, { signal: controller.signal }).subscribe({
|
||||
next: (v) => values.push(v),
|
||||
complete: () => { completed = true; },
|
||||
});
|
||||
|
||||
expect(values.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
controller.abort();
|
||||
// Give microtask time
|
||||
await new Promise<void>((r) => setTimeout(r, 10));
|
||||
|
||||
expect(completed).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('select with pre-aborted signal should complete immediately', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
const statePart = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
let completed = false;
|
||||
statePart.select(undefined, { signal: controller.signal }).subscribe({
|
||||
complete: () => { completed = true; },
|
||||
});
|
||||
|
||||
await new Promise<void>((r) => setTimeout(r, 10));
|
||||
expect(completed).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('waitUntilPresent should reject when AbortSignal fires', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
const statePart = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 0,
|
||||
nested: { data: '' }
|
||||
}, 'force');
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const promise = statePart.waitUntilPresent(
|
||||
(s) => s.value > 100 ? s : undefined as any,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
// Abort before the condition can be met
|
||||
setTimeout(() => controller.abort(), 20);
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await promise;
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toEqual('Aborted');
|
||||
});
|
||||
|
||||
tap.test('waitUntilPresent should still work with numeric timeout (backward compat)', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
const statePart = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 42,
|
||||
nested: { data: 'present' }
|
||||
}, 'force');
|
||||
|
||||
const result = await statePart.waitUntilPresent(undefined, 5000);
|
||||
expect(result.value).toEqual(42);
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Middleware tests
|
||||
// ============================
|
||||
|
||||
tap.test('middleware should transform state', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
const statePart = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
}, 'force');
|
||||
|
||||
statePart.addMiddleware((newState, oldState) => {
|
||||
return { ...newState, nested: { data: newState.nested.data.toUpperCase() } };
|
||||
});
|
||||
|
||||
await statePart.setState({ value: 2, nested: { data: 'hello' } });
|
||||
expect(statePart.getState().nested.data).toEqual('HELLO');
|
||||
});
|
||||
|
||||
tap.test('middleware should reject state changes on throw', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
const statePart = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
}, 'force');
|
||||
|
||||
statePart.addMiddleware((newState) => {
|
||||
if (newState.value < 0) {
|
||||
throw new Error('Value must be non-negative');
|
||||
}
|
||||
return newState;
|
||||
});
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await statePart.setState({ value: -1, nested: { data: 'bad' } });
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toEqual('Value must be non-negative');
|
||||
// State should be unchanged
|
||||
expect(statePart.getState().value).toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('multiple middlewares should run in order', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
const statePart = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
}, 'force');
|
||||
|
||||
const order: number[] = [];
|
||||
|
||||
statePart.addMiddleware((newState) => {
|
||||
order.push(1);
|
||||
return { ...newState, value: newState.value + 10 };
|
||||
});
|
||||
|
||||
statePart.addMiddleware((newState) => {
|
||||
order.push(2);
|
||||
return { ...newState, value: newState.value * 2 };
|
||||
});
|
||||
|
||||
await statePart.setState({ value: 5, nested: { data: 'test' } });
|
||||
expect(order).toEqual([1, 2]);
|
||||
// (5 + 10) * 2 = 30
|
||||
expect(statePart.getState().value).toEqual(30);
|
||||
});
|
||||
|
||||
tap.test('middleware removal should work', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
const statePart = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
}, 'force');
|
||||
|
||||
const remove = statePart.addMiddleware((newState) => {
|
||||
return { ...newState, value: newState.value * 100 };
|
||||
});
|
||||
|
||||
await statePart.setState({ value: 2, nested: { data: 'test' } });
|
||||
expect(statePart.getState().value).toEqual(200);
|
||||
|
||||
remove();
|
||||
|
||||
await statePart.setState({ value: 3, nested: { data: 'test' } });
|
||||
expect(statePart.getState().value).toEqual(3);
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Selector memoization tests
|
||||
// ============================
|
||||
|
||||
tap.test('select with same selector fn should return cached observable', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
const statePart = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
}, 'force');
|
||||
|
||||
const selector = (s: ITestState) => s.value;
|
||||
const obs1 = statePart.select(selector);
|
||||
const obs2 = statePart.select(selector);
|
||||
expect(obs1).toEqual(obs2);
|
||||
});
|
||||
|
||||
tap.test('select with no args should return cached observable', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
const statePart = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
}, 'force');
|
||||
|
||||
const obs1 = statePart.select();
|
||||
const obs2 = statePart.select();
|
||||
expect(obs1).toEqual(obs2);
|
||||
});
|
||||
|
||||
tap.test('select with different selectors should return different observables', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
const statePart = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
}, 'force');
|
||||
|
||||
const obs1 = statePart.select((s) => s.value);
|
||||
const obs2 = statePart.select((s) => s.nested);
|
||||
expect(obs1).not.toEqual(obs2);
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Batch update tests
|
||||
// ============================
|
||||
|
||||
tap.test('batch should defer notifications until complete', async () => {
|
||||
type TBatchParts = 'partA' | 'partB';
|
||||
const state = new smartstate.Smartstate<TBatchParts>();
|
||||
const partA = await state.getStatePart<ITestState>('partA', {
|
||||
value: 1,
|
||||
nested: { data: 'a' }
|
||||
});
|
||||
const partB = await state.getStatePart<ITestState>('partB', {
|
||||
value: 2,
|
||||
nested: { data: 'b' }
|
||||
});
|
||||
|
||||
const notificationsA: number[] = [];
|
||||
const notificationsB: number[] = [];
|
||||
|
||||
partA.select((s) => s.value).subscribe((v) => notificationsA.push(v));
|
||||
partB.select((s) => s.value).subscribe((v) => notificationsB.push(v));
|
||||
|
||||
// Reset after initial notifications
|
||||
notificationsA.length = 0;
|
||||
notificationsB.length = 0;
|
||||
|
||||
await state.batch(async () => {
|
||||
await partA.setState({ value: 10, nested: { data: 'aa' } });
|
||||
await partB.setState({ value: 20, nested: { data: 'bb' } });
|
||||
|
||||
// During batch, no notifications yet
|
||||
expect(notificationsA).toHaveLength(0);
|
||||
expect(notificationsB).toHaveLength(0);
|
||||
});
|
||||
|
||||
// After batch, both should have notified
|
||||
expect(notificationsA).toContain(10);
|
||||
expect(notificationsB).toContain(20);
|
||||
});
|
||||
|
||||
tap.test('nested batches should only flush at outermost level', async () => {
|
||||
type TBatchParts = 'nested';
|
||||
const state = new smartstate.Smartstate<TBatchParts>();
|
||||
const part = await state.getStatePart<ITestState>('nested', {
|
||||
value: 0,
|
||||
nested: { data: 'start' }
|
||||
});
|
||||
|
||||
const values: number[] = [];
|
||||
part.select((s) => s.value).subscribe((v) => values.push(v));
|
||||
values.length = 0;
|
||||
|
||||
await state.batch(async () => {
|
||||
await part.setState({ value: 1, nested: { data: 'a' } });
|
||||
|
||||
await state.batch(async () => {
|
||||
await part.setState({ value: 2, nested: { data: 'b' } });
|
||||
// Still inside outer batch
|
||||
expect(values).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Inner batch ended but outer batch still active
|
||||
expect(values).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Now outer batch is done — should see final notification
|
||||
expect(values.length).toBeGreaterThanOrEqual(1);
|
||||
expect(values[values.length - 1]).toEqual(2);
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Computed state tests
|
||||
// ============================
|
||||
|
||||
tap.test('computed should derive from multiple state parts', async () => {
|
||||
type TComputedParts = 'first' | 'second';
|
||||
const state = new smartstate.Smartstate<TComputedParts>();
|
||||
const first = await state.getStatePart<{ count: number }>('first', { count: 5 });
|
||||
const second = await state.getStatePart<{ count: number }>('second', { count: 10 });
|
||||
|
||||
const derived$ = state.computed(
|
||||
[first, second],
|
||||
(a, b) => a.count + b.count,
|
||||
);
|
||||
|
||||
const values: number[] = [];
|
||||
derived$.subscribe((v) => values.push(v));
|
||||
|
||||
expect(values).toContain(15);
|
||||
});
|
||||
|
||||
tap.test('computed should update when a source changes', async () => {
|
||||
type TComputedParts = 'x' | 'y';
|
||||
const state = new smartstate.Smartstate<TComputedParts>();
|
||||
const x = await state.getStatePart<{ n: number }>('x', { n: 1 });
|
||||
const y = await state.getStatePart<{ n: number }>('y', { n: 2 });
|
||||
|
||||
const derived$ = state.computed(
|
||||
[x, y],
|
||||
(xState, yState) => xState.n * yState.n,
|
||||
);
|
||||
|
||||
const values: number[] = [];
|
||||
derived$.subscribe((v) => values.push(v));
|
||||
|
||||
// Initial: 1 * 2 = 2
|
||||
expect(values[0]).toEqual(2);
|
||||
|
||||
await x.setState({ n: 5 });
|
||||
|
||||
// After update: 5 * 2 = 10
|
||||
expect(values[values.length - 1]).toEqual(10);
|
||||
});
|
||||
|
||||
tap.test('standalone computed function should work', async () => {
|
||||
type TParts = 'a' | 'b';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const a = await state.getStatePart<{ val: string }>('a', { val: 'hello' });
|
||||
const b = await state.getStatePart<{ val: string }>('b', { val: 'world' });
|
||||
|
||||
const derived$ = smartstate.computed(
|
||||
[a, b],
|
||||
(aState, bState) => `${aState.val} ${bState.val}`,
|
||||
);
|
||||
|
||||
const values: string[] = [];
|
||||
derived$.subscribe((v) => values.push(v));
|
||||
|
||||
expect(values[0]).toEqual('hello world');
|
||||
|
||||
await a.setState({ val: 'hi' });
|
||||
expect(values[values.length - 1]).toEqual('hi world');
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Context Protocol tests
|
||||
// ============================
|
||||
|
||||
tap.test('attachContextProvider should respond to context-request events', async () => {
|
||||
// EventTarget and CustomEvent are available in Node 18+
|
||||
if (typeof EventTarget === 'undefined') {
|
||||
console.log('Skipping context test — EventTarget not available');
|
||||
return;
|
||||
}
|
||||
|
||||
type TParts = 'ctx';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const statePart = await state.getStatePart<{ theme: string }>('ctx', { theme: 'dark' });
|
||||
|
||||
const myContext = Symbol('test-context');
|
||||
|
||||
// Use an EventTarget as a mock element
|
||||
const element = new EventTarget() as any as HTMLElement;
|
||||
|
||||
const cleanup = smartstate.attachContextProvider(element, {
|
||||
context: myContext,
|
||||
statePart,
|
||||
});
|
||||
|
||||
let receivedValue: any = null;
|
||||
|
||||
// Dispatch a context-request event
|
||||
const event = new CustomEvent('context-request', {
|
||||
detail: {
|
||||
context: myContext,
|
||||
callback: (value: any) => { receivedValue = value; },
|
||||
subscribe: false,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
});
|
||||
(element as any).dispatchEvent(event);
|
||||
|
||||
expect(receivedValue).toEqual({ theme: 'dark' });
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
tap.test('attachContextProvider should support subscriptions', async () => {
|
||||
if (typeof EventTarget === 'undefined') {
|
||||
console.log('Skipping context subscription test — EventTarget not available');
|
||||
return;
|
||||
}
|
||||
|
||||
type TParts = 'ctxSub';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const statePart = await state.getStatePart<{ count: number }>('ctxSub', { count: 0 });
|
||||
|
||||
const myContext = Symbol('sub-context');
|
||||
const element = new EventTarget() as any as HTMLElement;
|
||||
|
||||
const cleanup = smartstate.attachContextProvider(element, {
|
||||
context: myContext,
|
||||
statePart,
|
||||
});
|
||||
|
||||
const receivedValues: any[] = [];
|
||||
let unsubFn: (() => void) | undefined;
|
||||
|
||||
const event = new CustomEvent('context-request', {
|
||||
detail: {
|
||||
context: myContext,
|
||||
callback: (value: any, unsub?: () => void) => {
|
||||
receivedValues.push(value);
|
||||
if (unsub) unsubFn = unsub;
|
||||
},
|
||||
subscribe: true,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
});
|
||||
(element as any).dispatchEvent(event);
|
||||
|
||||
expect(receivedValues).toHaveLength(1);
|
||||
expect(receivedValues[0]).toEqual({ count: 0 });
|
||||
|
||||
// Update state — should trigger subscription callback
|
||||
await statePart.setState({ count: 42 });
|
||||
|
||||
// Give a tick for the subscription to fire
|
||||
await new Promise<void>((r) => setTimeout(r, 10));
|
||||
|
||||
expect(receivedValues.length).toBeGreaterThanOrEqual(2);
|
||||
expect(receivedValues[receivedValues.length - 1]).toEqual({ count: 42 });
|
||||
|
||||
// Unsubscribe
|
||||
expect(unsubFn).toBeDefined();
|
||||
unsubFn!();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
tap.test('attachContextProvider should ignore non-matching contexts', async () => {
|
||||
if (typeof EventTarget === 'undefined') {
|
||||
console.log('Skipping context mismatch test — EventTarget not available');
|
||||
return;
|
||||
}
|
||||
|
||||
type TParts = 'ctxMismatch';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const statePart = await state.getStatePart<{ v: number }>('ctxMismatch', { v: 1 });
|
||||
|
||||
const myContext = Symbol('my-context');
|
||||
const otherContext = Symbol('other-context');
|
||||
const element = new EventTarget() as any as HTMLElement;
|
||||
|
||||
const cleanup = smartstate.attachContextProvider(element, {
|
||||
context: myContext,
|
||||
statePart,
|
||||
});
|
||||
|
||||
let called = false;
|
||||
const event = new CustomEvent('context-request', {
|
||||
detail: {
|
||||
context: otherContext,
|
||||
callback: () => { called = true; },
|
||||
subscribe: false,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
});
|
||||
(element as any).dispatchEvent(event);
|
||||
|
||||
expect(called).toBeFalse();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Enterprise hardening tests
|
||||
// ============================
|
||||
|
||||
tap.test('concurrent dispatchAction should serialize (counter reaches exactly 10)', async () => {
|
||||
type TParts = 'counter';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const counter = await state.getStatePart<{ count: number }>('counter', { count: 0 });
|
||||
|
||||
const increment = counter.createAction<void>(async (statePart) => {
|
||||
const current = statePart.getState();
|
||||
return { count: current.count + 1 };
|
||||
});
|
||||
|
||||
// Fire 10 concurrent increments (no await)
|
||||
const promises: Promise<any>[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(counter.dispatchAction(increment, undefined));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(counter.getState().count).toEqual(10);
|
||||
});
|
||||
|
||||
tap.test('concurrent setState should serialize (no lost updates)', async () => {
|
||||
type TParts = 'concurrent';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ values: number[] }>('concurrent', { values: [] });
|
||||
|
||||
const promises: Promise<any>[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
promises.push(
|
||||
part.setState({ values: [...(part.getState()?.values || []), i] })
|
||||
);
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
// At minimum, the final state should have been set 5 times without error
|
||||
// The exact values depend on serialization timing, but state should be valid
|
||||
expect(part.getState()).toBeTruthy();
|
||||
expect(part.getState().values).toBeInstanceOf(Array);
|
||||
});
|
||||
|
||||
tap.test('dispose should complete the Subject and notify subscribers', async () => {
|
||||
type TParts = 'disposable';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ v: number }>('disposable', { v: 1 });
|
||||
|
||||
let completed = false;
|
||||
part.select().subscribe({
|
||||
complete: () => { completed = true; },
|
||||
});
|
||||
|
||||
part.dispose();
|
||||
|
||||
expect(completed).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('falsy state {count: 0} should trigger notification', async () => {
|
||||
type TParts = 'falsy';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ count: number }>('falsy', { count: 0 });
|
||||
|
||||
const values: number[] = [];
|
||||
part.select((s) => s.count).subscribe((v) => values.push(v));
|
||||
|
||||
// Initial value should include 0
|
||||
expect(values).toContain(0);
|
||||
|
||||
await part.setState({ count: 0 });
|
||||
// Should not duplicate since hash is the same, but the initial notification should have fired
|
||||
expect(values.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
tap.test('waitUntilPresent should resolve for falsy non-null values like false', async () => {
|
||||
type TParts = 'falsyWait';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ flag: boolean }>('falsyWait', { flag: false });
|
||||
|
||||
const result = await part.waitUntilPresent((s) => s.flag as any, 2000);
|
||||
// false is not null/undefined, so it should resolve
|
||||
// Actually false IS falsy for `value !== undefined && value !== null` — false passes that check
|
||||
expect(result).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('batch re-entrancy: setState during flush should not deadlock', async () => {
|
||||
type TParts = 'reentrant';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ v: number }>('reentrant', { v: 0 });
|
||||
|
||||
let flushSetStateDone = false;
|
||||
|
||||
// Subscribe and trigger a setState during notification flush
|
||||
part.select((s) => s.v).subscribe((v) => {
|
||||
if (v === 1 && !flushSetStateDone) {
|
||||
flushSetStateDone = true;
|
||||
// Fire-and-forget setState during notification — should not deadlock
|
||||
part.setState({ v: 2 });
|
||||
}
|
||||
});
|
||||
|
||||
await state.batch(async () => {
|
||||
await part.setState({ v: 1 });
|
||||
});
|
||||
|
||||
// Wait for the fire-and-forget setState to complete
|
||||
await new Promise<void>((r) => setTimeout(r, 50));
|
||||
|
||||
expect(part.getState().v).toEqual(2);
|
||||
});
|
||||
|
||||
tap.test('force mode should dispose old StatePart (Subject completes)', async () => {
|
||||
type TParts = 'forceDispose';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const old = await state.getStatePart<{ v: number }>('forceDispose', { v: 1 });
|
||||
|
||||
let oldCompleted = false;
|
||||
old.select().subscribe({
|
||||
complete: () => { oldCompleted = true; },
|
||||
});
|
||||
|
||||
await state.getStatePart<{ v: number }>('forceDispose', { v: 2 }, 'force');
|
||||
|
||||
expect(oldCompleted).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('getStatePart should accept 0 as initial value', async () => {
|
||||
type TParts = 'zeroInit';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
|
||||
// 0 is falsy but should be accepted as a valid initial value
|
||||
const part = await state.getStatePart<number>('zeroInit', 0);
|
||||
expect(part.getState()).toEqual(0);
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Action context re-entrancy tests
|
||||
// ============================
|
||||
|
||||
tap.test('context.dispatch should not deadlock on same StatePart', async () => {
|
||||
type TParts = 'reentrantAction';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ count: number }>('reentrantAction', { count: 0 });
|
||||
|
||||
const innerIncrement = part.createAction<void>(async (sp) => {
|
||||
return { count: sp.getState().count + 1 };
|
||||
});
|
||||
|
||||
const outerAction = part.createAction<void>(async (sp, _payload, context) => {
|
||||
// This would deadlock without the context.dispatch() mechanism
|
||||
await context.dispatch(innerIncrement, undefined);
|
||||
return sp.getState();
|
||||
});
|
||||
|
||||
const result = await part.dispatchAction(outerAction, undefined);
|
||||
expect(result.count).toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('deeply nested context.dispatch should work (3 levels)', async () => {
|
||||
type TParts = 'deepNested';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ steps: string[] }>('deepNested', { steps: [] });
|
||||
|
||||
const appendStep = part.createAction<string>(async (sp, step) => {
|
||||
return { steps: [...sp.getState().steps, step] };
|
||||
});
|
||||
|
||||
const level2 = part.createAction<void>(async (sp, _payload, context) => {
|
||||
await context.dispatch(appendStep, 'level-2');
|
||||
return sp.getState();
|
||||
});
|
||||
|
||||
const level1 = part.createAction<void>(async (sp, _payload, context) => {
|
||||
await context.dispatch(appendStep, 'level-1');
|
||||
await context.dispatch(level2, undefined);
|
||||
await context.dispatch(appendStep, 'level-1-after');
|
||||
return sp.getState();
|
||||
});
|
||||
|
||||
await part.dispatchAction(level1, undefined);
|
||||
expect(part.getState().steps).toEqual(['level-1', 'level-2', 'level-1-after']);
|
||||
});
|
||||
|
||||
tap.test('circular context.dispatch should throw max depth error', async () => {
|
||||
type TParts = 'circular';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ count: number }>('circular', { count: 0 });
|
||||
|
||||
// Create a self-referencing action that will loop forever
|
||||
const circularAction: smartstate.StateAction<{ count: number }, void> = part.createAction<void>(
|
||||
async (sp, _payload, context) => {
|
||||
const current = sp.getState();
|
||||
if (current.count < 100) {
|
||||
// This should eventually hit the depth limit
|
||||
await context.dispatch(circularAction, undefined);
|
||||
}
|
||||
return sp.getState();
|
||||
}
|
||||
);
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await part.dispatchAction(circularAction, undefined);
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toMatch(/Maximum nested action dispatch depth/);
|
||||
});
|
||||
|
||||
tap.test('actions without context arg should still work (backward compat)', async () => {
|
||||
type TParts = 'backwardCompat';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ value: number }>('backwardCompat', { value: 0 });
|
||||
|
||||
// Old-style action that doesn't use the context parameter
|
||||
const simpleAction = part.createAction<number>(async (sp, payload) => {
|
||||
return { value: payload };
|
||||
});
|
||||
|
||||
await part.dispatchAction(simpleAction, 42);
|
||||
expect(part.getState().value).toEqual(42);
|
||||
});
|
||||
|
||||
tap.test('concurrent dispatches still serialize correctly with context feature', async () => {
|
||||
type TParts = 'concurrentWithContext';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ count: number }>('concurrentWithContext', { count: 0 });
|
||||
|
||||
const increment = part.createAction<void>(async (sp) => {
|
||||
const current = sp.getState();
|
||||
return { count: current.count + 1 };
|
||||
});
|
||||
|
||||
// Fire 10 concurrent dispatches — must still serialize
|
||||
const promises: Promise<any>[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(part.dispatchAction(increment, undefined));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(part.getState().count).toEqual(10);
|
||||
});
|
||||
|
||||
// ── distinctUntilChanged on selectors ──────────────────────────────────
|
||||
tap.test('select should not emit when selected sub-state has not changed', async () => {
|
||||
type TParts = 'distinctSelector';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ name: string; count: number }>('distinctSelector', { name: 'alice', count: 0 });
|
||||
|
||||
const nameSelector = (s: { name: string; count: number }) => s.name;
|
||||
const nameValues: string[] = [];
|
||||
part.select(nameSelector).subscribe((v) => nameValues.push(v));
|
||||
|
||||
// Wait for initial emission
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(nameValues).toHaveLength(1);
|
||||
expect(nameValues[0]).toEqual('alice');
|
||||
|
||||
// Change only count — name selector should NOT re-emit
|
||||
await part.setState({ name: 'alice', count: 1 });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(nameValues).toHaveLength(1);
|
||||
|
||||
// Change name — name selector SHOULD emit
|
||||
await part.setState({ name: 'bob', count: 1 });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(nameValues).toHaveLength(2);
|
||||
expect(nameValues[1]).toEqual('bob');
|
||||
});
|
||||
|
||||
// ── Selector error skipping ────────────────────────────────────────────
|
||||
tap.test('selector errors should be skipped, not emit undefined', async () => {
|
||||
type TParts = 'selectorError';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ value: number }>('selectorError', { value: 1 });
|
||||
|
||||
let callCount = 0;
|
||||
const faultySelector = (s: { value: number }) => {
|
||||
if (s.value === 2) throw new Error('selector boom');
|
||||
return s.value;
|
||||
};
|
||||
|
||||
const values: number[] = [];
|
||||
part.select(faultySelector).subscribe((v) => {
|
||||
callCount++;
|
||||
values.push(v);
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(values).toEqual([1]);
|
||||
|
||||
// This setState triggers a selector error — should be skipped, no undefined emitted
|
||||
await part.setState({ value: 2 });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(values).toEqual([1]); // no new emission
|
||||
expect(callCount).toEqual(1);
|
||||
|
||||
// Normal value again
|
||||
await part.setState({ value: 3 });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(values).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
// ── Smartstate.dispose() ───────────────────────────────────────────────
|
||||
tap.test('Smartstate.dispose should dispose all state parts', async () => {
|
||||
type TParts = 'partA' | 'partB';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const partA = await state.getStatePart<{ x: number }>('partA', { x: 1 });
|
||||
const partB = await state.getStatePart<{ y: number }>('partB', { y: 2 });
|
||||
|
||||
let aCompleted = false;
|
||||
let bCompleted = false;
|
||||
partA.select().subscribe({ complete: () => { aCompleted = true; } });
|
||||
partB.select().subscribe({ complete: () => { bCompleted = true; } });
|
||||
|
||||
state.dispose();
|
||||
|
||||
expect(aCompleted).toBeTrue();
|
||||
expect(bCompleted).toBeTrue();
|
||||
});
|
||||
|
||||
// ── Post-dispose setState throws ───────────────────────────────────────
|
||||
tap.test('setState on disposed StatePart should throw', async () => {
|
||||
type TParts = 'disposedPart';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ v: number }>('disposedPart', { v: 0 });
|
||||
|
||||
part.dispose();
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
await part.setState({ v: 1 });
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
expect((e as Error).message).toInclude('disposed');
|
||||
}
|
||||
expect(threw).toBeTrue();
|
||||
});
|
||||
|
||||
// ── Post-dispose dispatchAction throws ─────────────────────────────────
|
||||
tap.test('dispatchAction on disposed StatePart should throw', async () => {
|
||||
type TParts = 'disposedDispatch';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ v: number }>('disposedDispatch', { v: 0 });
|
||||
|
||||
const action = part.createAction<number>(async (sp, payload) => {
|
||||
return { v: payload };
|
||||
});
|
||||
|
||||
part.dispose();
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
await part.dispatchAction(action, 5);
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
expect((e as Error).message).toInclude('disposed');
|
||||
}
|
||||
expect(threw).toBeTrue();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
278
test/test.stateprocess.ts
Normal file
278
test/test.stateprocess.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartstate from '../ts/index.js';
|
||||
import { Subject, of, Observable, throwError, concat } from 'rxjs';
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────
|
||||
tap.test('process should start in idle status', async () => {
|
||||
const state = new smartstate.Smartstate<'test'>();
|
||||
const part = await state.getStatePart<{ v: number }>('test', { v: 0 });
|
||||
const process = part.createProcess<number>({
|
||||
producer: () => of(1),
|
||||
reducer: (s, v) => ({ v: s.v + v }),
|
||||
});
|
||||
expect(process.status).toEqual('idle');
|
||||
process.dispose();
|
||||
});
|
||||
|
||||
tap.test('start/pause/resume/dispose lifecycle', async () => {
|
||||
const state = new smartstate.Smartstate<'lifecycle'>();
|
||||
const part = await state.getStatePart<{ v: number }>('lifecycle', { v: 0 });
|
||||
const subject = new Subject<number>();
|
||||
const process = part.createProcess<number>({
|
||||
producer: () => subject.asObservable(),
|
||||
reducer: (s, v) => ({ v: s.v + v }),
|
||||
});
|
||||
|
||||
expect(process.status).toEqual('idle');
|
||||
process.start();
|
||||
expect(process.status).toEqual('running');
|
||||
process.pause();
|
||||
expect(process.status).toEqual('paused');
|
||||
process.resume();
|
||||
expect(process.status).toEqual('running');
|
||||
process.dispose();
|
||||
expect(process.status).toEqual('disposed');
|
||||
});
|
||||
|
||||
// ── Producer → state integration ───────────────────────────────────────
|
||||
tap.test('producer values should update state through reducer', async () => {
|
||||
const state = new smartstate.Smartstate<'producer'>();
|
||||
const part = await state.getStatePart<{ values: number[] }>('producer', { values: [] });
|
||||
const subject = new Subject<number>();
|
||||
|
||||
const process = part.createProcess<number>({
|
||||
producer: () => subject.asObservable(),
|
||||
reducer: (s, v) => ({ values: [...s.values, v] }),
|
||||
});
|
||||
process.start();
|
||||
|
||||
subject.next(1);
|
||||
subject.next(2);
|
||||
subject.next(3);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect(part.getState()!.values).toEqual([1, 2, 3]);
|
||||
process.dispose();
|
||||
});
|
||||
|
||||
// ── Pause stops producer, resume restarts ──────────────────────────────
|
||||
tap.test('pause should stop receiving values, resume should restart', async () => {
|
||||
const state = new smartstate.Smartstate<'pauseResume'>();
|
||||
const part = await state.getStatePart<{ count: number }>('pauseResume', { count: 0 });
|
||||
const subject = new Subject<number>();
|
||||
|
||||
const process = part.createProcess<number>({
|
||||
producer: () => subject.asObservable(),
|
||||
reducer: (s, v) => ({ count: s.count + v }),
|
||||
});
|
||||
process.start();
|
||||
|
||||
subject.next(1);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(part.getState()!.count).toEqual(1);
|
||||
|
||||
process.pause();
|
||||
subject.next(1); // should be ignored — producer unsubscribed
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(part.getState()!.count).toEqual(1); // unchanged
|
||||
|
||||
process.resume();
|
||||
subject.next(1);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(part.getState()!.count).toEqual(2);
|
||||
|
||||
process.dispose();
|
||||
});
|
||||
|
||||
// ── Auto-pause with custom Observable ──────────────────────────────────
|
||||
tap.test('auto-pause with custom Observable<boolean> signal', async () => {
|
||||
const state = new smartstate.Smartstate<'autoPause'>();
|
||||
const part = await state.getStatePart<{ count: number }>('autoPause', { count: 0 });
|
||||
const producer = new Subject<number>();
|
||||
const pauseSignal = new Subject<boolean>();
|
||||
|
||||
const process = part.createProcess<number>({
|
||||
producer: () => producer.asObservable(),
|
||||
reducer: (s, v) => ({ count: s.count + v }),
|
||||
autoPause: pauseSignal.asObservable(),
|
||||
});
|
||||
process.start();
|
||||
|
||||
producer.next(1);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(part.getState()!.count).toEqual(1);
|
||||
|
||||
// Signal pause
|
||||
pauseSignal.next(false);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(process.status).toEqual('paused');
|
||||
|
||||
producer.next(1); // ignored
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(part.getState()!.count).toEqual(1);
|
||||
|
||||
// Signal resume
|
||||
pauseSignal.next(true);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(process.status).toEqual('running');
|
||||
|
||||
producer.next(1);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(part.getState()!.count).toEqual(2);
|
||||
|
||||
process.dispose();
|
||||
});
|
||||
|
||||
// ── Auto-pause 'visibility' in Node.js (no document) ──────────────────
|
||||
tap.test('autoPause visibility should be always-active in Node.js', async () => {
|
||||
const state = new smartstate.Smartstate<'vis'>();
|
||||
const part = await state.getStatePart<{ v: number }>('vis', { v: 0 });
|
||||
const subject = new Subject<number>();
|
||||
|
||||
const process = part.createProcess<number>({
|
||||
producer: () => subject.asObservable(),
|
||||
reducer: (s, v) => ({ v: v }),
|
||||
autoPause: 'visibility',
|
||||
});
|
||||
process.start();
|
||||
expect(process.status).toEqual('running');
|
||||
|
||||
subject.next(42);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(part.getState()!.v).toEqual(42);
|
||||
process.dispose();
|
||||
});
|
||||
|
||||
// ── Scheduled action ───────────────────────────────────────────────────
|
||||
tap.test('createScheduledAction should dispatch action on interval', async () => {
|
||||
const state = new smartstate.Smartstate<'scheduled'>();
|
||||
const part = await state.getStatePart<{ ticks: number }>('scheduled', { ticks: 0 });
|
||||
|
||||
const tickAction = part.createAction<void>(async (sp) => {
|
||||
return { ticks: sp.getState()!.ticks + 1 };
|
||||
});
|
||||
|
||||
const scheduled = part.createScheduledAction({
|
||||
action: tickAction,
|
||||
payload: undefined,
|
||||
intervalMs: 50,
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 280));
|
||||
scheduled.dispose();
|
||||
|
||||
// Should have ticked at least 3 times in ~280ms with 50ms interval
|
||||
expect(part.getState()!.ticks).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
// ── StatePart.dispose cascades ─────────────────────────────────────────
|
||||
tap.test('StatePart.dispose should dispose all processes', async () => {
|
||||
const state = new smartstate.Smartstate<'cascade'>();
|
||||
const part = await state.getStatePart<{ v: number }>('cascade', { v: 0 });
|
||||
|
||||
const p1 = part.createProcess<number>({
|
||||
producer: () => new Subject<number>().asObservable(),
|
||||
reducer: (s, v) => ({ v }),
|
||||
});
|
||||
const p2 = part.createProcess<number>({
|
||||
producer: () => new Subject<number>().asObservable(),
|
||||
reducer: (s, v) => ({ v }),
|
||||
});
|
||||
p1.start();
|
||||
p2.start();
|
||||
|
||||
part.dispose();
|
||||
expect(p1.status).toEqual('disposed');
|
||||
expect(p2.status).toEqual('disposed');
|
||||
});
|
||||
|
||||
// ── status$ observable ─────────────────────────────────────────────────
|
||||
tap.test('status$ should emit lifecycle transitions', async () => {
|
||||
const state = new smartstate.Smartstate<'status$'>();
|
||||
const part = await state.getStatePart<{ v: number }>('status$', { v: 0 });
|
||||
const subject = new Subject<number>();
|
||||
|
||||
const process = part.createProcess<number>({
|
||||
producer: () => subject.asObservable(),
|
||||
reducer: (s, v) => ({ v }),
|
||||
});
|
||||
|
||||
const statuses: string[] = [];
|
||||
process.status$.subscribe((s) => statuses.push(s));
|
||||
|
||||
process.start();
|
||||
process.pause();
|
||||
process.resume();
|
||||
process.dispose();
|
||||
|
||||
expect(statuses).toEqual(['idle', 'running', 'paused', 'running', 'disposed']);
|
||||
});
|
||||
|
||||
// ── Producer error → graceful pause ────────────────────────────────────
|
||||
tap.test('producer error should pause process gracefully', async () => {
|
||||
const state = new smartstate.Smartstate<'error'>();
|
||||
const part = await state.getStatePart<{ v: number }>('error', { v: 0 });
|
||||
|
||||
let callCount = 0;
|
||||
const process = part.createProcess<number>({
|
||||
producer: () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
// First subscription: emit 1, then error
|
||||
return concat(of(1), throwError(() => new Error('boom')));
|
||||
}
|
||||
// After resume: emit 2 successfully
|
||||
return of(2);
|
||||
},
|
||||
reducer: (s, v) => ({ v }),
|
||||
});
|
||||
process.start();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(process.status).toEqual('paused');
|
||||
expect(part.getState()!.v).toEqual(1); // got the value before error
|
||||
|
||||
// Resume creates a fresh subscription
|
||||
process.resume();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(part.getState()!.v).toEqual(2);
|
||||
|
||||
process.dispose();
|
||||
});
|
||||
|
||||
// ── Disposed guards ────────────────────────────────────────────────────
|
||||
tap.test('start/pause/resume on disposed process should throw', async () => {
|
||||
const state = new smartstate.Smartstate<'guards'>();
|
||||
const part = await state.getStatePart<{ v: number }>('guards', { v: 0 });
|
||||
|
||||
const process = part.createProcess<number>({
|
||||
producer: () => of(1),
|
||||
reducer: (s, v) => ({ v }),
|
||||
});
|
||||
process.dispose();
|
||||
|
||||
let errors = 0;
|
||||
try { process.start(); } catch { errors++; }
|
||||
try { process.pause(); } catch { errors++; }
|
||||
try { process.resume(); } catch { errors++; }
|
||||
expect(errors).toEqual(3);
|
||||
});
|
||||
|
||||
// ── autoStart option ───────────────────────────────────────────────────
|
||||
tap.test('autoStart should start process immediately', async () => {
|
||||
const state = new smartstate.Smartstate<'autoStart'>();
|
||||
const part = await state.getStatePart<{ v: number }>('autoStart', { v: 0 });
|
||||
|
||||
const process = part.createProcess<number>({
|
||||
producer: () => of(42),
|
||||
reducer: (s, v) => ({ v }),
|
||||
autoStart: true,
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(process.status).toEqual('running');
|
||||
expect(part.getState()!.v).toEqual(42);
|
||||
process.dispose();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -18,7 +18,7 @@ tap.test('should create a new SmartState', async () => {
|
||||
});
|
||||
|
||||
tap.test('should create a new StatePart', async () => {
|
||||
testStatePart = testState.getStatePart<TStatePartPayload>('testStatePart', {
|
||||
testStatePart = await testState.getStatePart<TStatePartPayload>('testStatePart', {
|
||||
currentFavorites: [],
|
||||
deep: {
|
||||
hi: 2,
|
||||
@@ -40,8 +40,10 @@ tap.test('should dispatch a state action', async (tools) => {
|
||||
const done = tools.defer();
|
||||
const addFavourite = testStatePart.createAction<string>(async (statePart, payload) => {
|
||||
const currentState = statePart.getState();
|
||||
currentState.currentFavorites.push(payload);
|
||||
return currentState;
|
||||
return {
|
||||
...currentState,
|
||||
currentFavorites: [...currentState.currentFavorites, payload],
|
||||
};
|
||||
});
|
||||
testStatePart
|
||||
.waitUntilPresent((state) => {
|
||||
@@ -55,4 +57,4 @@ tap.test('should dispatch a state action', async (tools) => {
|
||||
await done.promise;
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @pushrocks/commitinfo
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartstate',
|
||||
version: '2.0.13',
|
||||
description: 'a package that handles state in a good way'
|
||||
version: '2.3.0',
|
||||
description: 'A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support.'
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export * from './smartstate.classes.smartstate.js';
|
||||
export * from './smartstate.classes.statepart.js';
|
||||
export * from './smartstate.classes.stateaction.js';
|
||||
export * from './smartstate.classes.computed.js';
|
||||
export * from './smartstate.contextprovider.js';
|
||||
export * from './smartstate.classes.stateprocess.js';
|
||||
|
||||
17
ts/smartstate.classes.computed.ts
Normal file
17
ts/smartstate.classes.computed.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as plugins from './smartstate.plugins.js';
|
||||
import { combineLatest, map, distinctUntilChanged } from 'rxjs';
|
||||
import type { StatePart } from './smartstate.classes.statepart.js';
|
||||
|
||||
/**
|
||||
* creates a computed observable derived from multiple state parts.
|
||||
* the observable is lazy — it only subscribes to sources when subscribed to.
|
||||
*/
|
||||
export function computed<TResult>(
|
||||
sources: StatePart<any, any>[],
|
||||
computeFn: (...states: any[]) => TResult,
|
||||
): plugins.smartrx.rxjs.Observable<TResult> {
|
||||
return combineLatest(sources.map((sp) => sp.select())).pipe(
|
||||
map((states) => computeFn(...states)),
|
||||
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
|
||||
) as plugins.smartrx.rxjs.Observable<TResult>;
|
||||
}
|
||||
@@ -1,66 +1,173 @@
|
||||
import * as plugins from './smartstate.plugins.js';
|
||||
import { StatePart } from './smartstate.classes.statepart.js';
|
||||
import { computed } from './smartstate.classes.computed.js';
|
||||
|
||||
export type TInitMode = 'soft' | 'mandatory' | 'force' | 'persistent';
|
||||
|
||||
/**
|
||||
* Smartstate takes care of providing state
|
||||
*/
|
||||
export class Smartstate<StatePartNameType> {
|
||||
public statePartMap: { [key: string]: StatePart<StatePartNameType, any> } = {};
|
||||
export class Smartstate<StatePartNameType extends string> {
|
||||
public statePartMap: { [key in StatePartNameType]?: StatePart<StatePartNameType, any> } = {};
|
||||
|
||||
private pendingStatePartCreation: Map<string, Promise<StatePart<StatePartNameType, any>>> = new Map();
|
||||
|
||||
// Batch support
|
||||
private batchDepth = 0;
|
||||
private isFlushing = false;
|
||||
private pendingNotifications = new Set<StatePart<any, any>>();
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Allows getting and initializing a new statepart
|
||||
* initMode === 'soft' it will allow existing stateparts
|
||||
* initMode === 'mandatory' will fail if there is an exiting statepart
|
||||
* initMode === 'force' will overwrite any existing statepart
|
||||
* @param statePartNameArg
|
||||
* @param initialArg
|
||||
* @param initMode
|
||||
* whether state changes are currently being batched
|
||||
*/
|
||||
public getStatePart<PayloadType>(
|
||||
statePartNameArg: string & StatePartNameType,
|
||||
initialArg?: PayloadType,
|
||||
initMode?: TInitMode
|
||||
): StatePart<StatePartNameType, PayloadType> {
|
||||
if (this.statePartMap[statePartNameArg as any]) {
|
||||
if (initialArg && (!initMode || initMode !== 'soft')) {
|
||||
throw new Error(
|
||||
`${statePartNameArg} already exists, yet you try to set an initial state again`
|
||||
);
|
||||
public get isBatching(): boolean {
|
||||
return this.batchDepth > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* registers a state part for deferred notification during a batch
|
||||
*/
|
||||
public registerPendingNotification(statePart: StatePart<any, any>): void {
|
||||
this.pendingNotifications.add(statePart);
|
||||
}
|
||||
|
||||
/**
|
||||
* batches multiple state updates so subscribers are only notified once all updates complete
|
||||
*/
|
||||
public async batch(updateFn: () => Promise<void> | void): Promise<void> {
|
||||
this.batchDepth++;
|
||||
try {
|
||||
await updateFn();
|
||||
} finally {
|
||||
this.batchDepth--;
|
||||
if (this.batchDepth === 0 && !this.isFlushing) {
|
||||
this.isFlushing = true;
|
||||
try {
|
||||
while (this.pendingNotifications.size > 0) {
|
||||
const pending = [...this.pendingNotifications];
|
||||
this.pendingNotifications.clear();
|
||||
for (const sp of pending) {
|
||||
try {
|
||||
await sp.notifyChange();
|
||||
} catch (err) {
|
||||
console.error(`Error flushing notification for state part:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isFlushing = false;
|
||||
}
|
||||
}
|
||||
return this.statePartMap[statePartNameArg] as StatePart<StatePartNameType, PayloadType>;
|
||||
} else {
|
||||
if (!initialArg) {
|
||||
throw new Error(
|
||||
`${statePartNameArg} does not yet exist, yet you don't provide an initial state`
|
||||
);
|
||||
}
|
||||
return this.createStatePart<PayloadType>(statePartNameArg, initialArg, initMode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* creates a statepart
|
||||
* @param statePartName
|
||||
* @param initialPayloadArg
|
||||
* creates a computed observable derived from multiple state parts
|
||||
*/
|
||||
private createStatePart<PayloadType>(
|
||||
public computed<TResult>(
|
||||
sources: StatePart<StatePartNameType, any>[],
|
||||
computeFn: (...states: any[]) => TResult,
|
||||
): plugins.smartrx.rxjs.Observable<TResult> {
|
||||
return computed(sources, computeFn);
|
||||
}
|
||||
|
||||
/**
|
||||
* disposes all state parts and clears internal state
|
||||
*/
|
||||
public dispose(): void {
|
||||
for (const key of Object.keys(this.statePartMap)) {
|
||||
const part = this.statePartMap[key as StatePartNameType];
|
||||
if (part) {
|
||||
part.dispose();
|
||||
}
|
||||
}
|
||||
this.statePartMap = {} as any;
|
||||
this.pendingStatePartCreation.clear();
|
||||
this.pendingNotifications.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows getting and initializing a new statepart
|
||||
*/
|
||||
public async getStatePart<PayloadType>(
|
||||
statePartNameArg: StatePartNameType,
|
||||
initialArg?: PayloadType,
|
||||
initMode: TInitMode = 'soft'
|
||||
): Promise<StatePart<StatePartNameType, PayloadType>> {
|
||||
// Return pending creation if one exists to prevent duplicate state parts
|
||||
const pending = this.pendingStatePartCreation.get(statePartNameArg);
|
||||
if (pending) {
|
||||
return pending as Promise<StatePart<StatePartNameType, PayloadType>>;
|
||||
}
|
||||
|
||||
const existingStatePart = this.statePartMap[statePartNameArg];
|
||||
|
||||
if (existingStatePart) {
|
||||
switch (initMode) {
|
||||
case 'mandatory':
|
||||
throw new Error(
|
||||
`State part '${statePartNameArg}' already exists, but initMode is 'mandatory'`
|
||||
);
|
||||
case 'force':
|
||||
existingStatePart.dispose();
|
||||
break;
|
||||
case 'soft':
|
||||
case 'persistent':
|
||||
default:
|
||||
return existingStatePart as StatePart<StatePartNameType, PayloadType>;
|
||||
}
|
||||
} else {
|
||||
if (initialArg === undefined) {
|
||||
throw new Error(
|
||||
`State part '${statePartNameArg}' does not exist and no initial state provided`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const creationPromise = this.createStatePart<PayloadType>(statePartNameArg, initialArg!, initMode);
|
||||
this.pendingStatePartCreation.set(statePartNameArg, creationPromise);
|
||||
|
||||
try {
|
||||
const result = await creationPromise;
|
||||
return result;
|
||||
} finally {
|
||||
this.pendingStatePartCreation.delete(statePartNameArg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a statepart
|
||||
*/
|
||||
private async createStatePart<PayloadType>(
|
||||
statePartName: StatePartNameType,
|
||||
initialPayloadArg: PayloadType,
|
||||
initMode?: TInitMode
|
||||
): StatePart<StatePartNameType, PayloadType> {
|
||||
initMode: TInitMode = 'soft'
|
||||
): Promise<StatePart<StatePartNameType, PayloadType>> {
|
||||
const newState = new StatePart<StatePartNameType, PayloadType>(
|
||||
statePartName,
|
||||
initMode === 'persistent' ? {
|
||||
dbName: 'smartstate',
|
||||
storeName: statePartName as any,
|
||||
} : null
|
||||
initMode === 'persistent'
|
||||
? {
|
||||
dbName: 'smartstate',
|
||||
storeName: statePartName,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
newState.setState(initialPayloadArg);
|
||||
this.statePartMap[statePartName as any] = newState;
|
||||
newState.smartstateRef = this;
|
||||
await newState.init();
|
||||
const currentState = newState.getState();
|
||||
|
||||
if (initMode === 'persistent' && currentState !== undefined) {
|
||||
await newState.setState({
|
||||
...initialPayloadArg,
|
||||
...currentState,
|
||||
});
|
||||
} else {
|
||||
await newState.setState(initialPayloadArg);
|
||||
}
|
||||
|
||||
this.statePartMap[statePartName] = newState;
|
||||
return newState;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import * as plugins from './smartstate.plugins.js';
|
||||
import { StatePart } from './smartstate.classes.statepart.js';
|
||||
|
||||
/**
|
||||
* Context object passed to action definitions, enabling safe nested dispatch.
|
||||
* Use `context.dispatch()` to dispatch sub-actions inline (bypasses the mutation queue).
|
||||
* Direct `statePart.dispatchAction()` from within an action will deadlock.
|
||||
*/
|
||||
export interface IActionContext<TStateType> {
|
||||
dispatch<T>(action: StateAction<TStateType, T>, payload: T): Promise<TStateType>;
|
||||
}
|
||||
|
||||
export interface IActionDef<TStateType, TActionPayloadType> {
|
||||
(stateArg: StatePart<any, TStateType>, actionPayload: TActionPayloadType): Promise<TStateType>;
|
||||
(stateArg: StatePart<any, TStateType>, actionPayload: TActionPayloadType, context?: IActionContext<TStateType>): Promise<TStateType>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -10,11 +18,11 @@ export interface IActionDef<TStateType, TActionPayloadType> {
|
||||
*/
|
||||
export class StateAction<TStateType, TActionPayloadType> {
|
||||
constructor(
|
||||
public statePartRef: StatePart<any, any>,
|
||||
public actionDef: IActionDef<TStateType, TActionPayloadType>
|
||||
public readonly statePartRef: StatePart<any, any>,
|
||||
public readonly actionDef: IActionDef<TStateType, TActionPayloadType>
|
||||
) {}
|
||||
|
||||
public trigger(payload: TActionPayloadType) {
|
||||
this.statePartRef.dispatchAction(this, payload);
|
||||
public trigger(payload: TActionPayloadType): Promise<TStateType> {
|
||||
return this.statePartRef.dispatchAction(this, payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,77 @@
|
||||
import * as plugins from './smartstate.plugins.js';
|
||||
import { StateAction, type IActionDef } from './smartstate.classes.stateaction.js';
|
||||
import { BehaviorSubject, Observable, shareReplay, takeUntil, distinctUntilChanged, interval } from 'rxjs';
|
||||
import { StateAction, type IActionDef, type IActionContext } from './smartstate.classes.stateaction.js';
|
||||
import type { Smartstate } from './smartstate.classes.smartstate.js';
|
||||
import { StateProcess, type IProcessOptions, type IScheduledActionOptions } from './smartstate.classes.stateprocess.js';
|
||||
|
||||
export type TMiddleware<TPayload> = (
|
||||
newState: TPayload,
|
||||
oldState: TPayload | undefined,
|
||||
) => TPayload | Promise<TPayload>;
|
||||
|
||||
/**
|
||||
* creates an Observable that emits once when the given AbortSignal fires
|
||||
*/
|
||||
function fromAbortSignal(signal: AbortSignal): Observable<void> {
|
||||
return new Observable<void>((subscriber) => {
|
||||
if (signal.aborted) {
|
||||
subscriber.next();
|
||||
subscriber.complete();
|
||||
return;
|
||||
}
|
||||
const handler = () => {
|
||||
subscriber.next();
|
||||
subscriber.complete();
|
||||
};
|
||||
signal.addEventListener('abort', handler);
|
||||
return () => signal.removeEventListener('abort', handler);
|
||||
});
|
||||
}
|
||||
|
||||
export class StatePart<TStatePartName, TStatePayload> {
|
||||
private static readonly MAX_NESTED_DISPATCH_DEPTH = 10;
|
||||
|
||||
public name: TStatePartName;
|
||||
public state = new plugins.smartrx.rxjs.Subject<TStatePayload>();
|
||||
public stateStore: TStatePayload;
|
||||
private state = new BehaviorSubject<TStatePayload | undefined>(undefined);
|
||||
private stateStore: TStatePayload | undefined;
|
||||
public smartstateRef?: Smartstate<any>;
|
||||
private disposed = false;
|
||||
private cumulativeDeferred = plugins.smartpromise.cumulativeDefer();
|
||||
|
||||
private webStore: plugins.webstore.WebStore<TStatePayload> | null = null; // Add WebStore instance
|
||||
private mutationQueue: Promise<any> = Promise.resolve();
|
||||
private pendingCumulativeNotification: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(nameArg: TStatePartName, webStoreOptions?: plugins.webstore.IWebStoreOptions) {
|
||||
private webStoreOptions: plugins.webstore.IWebStoreOptions | undefined;
|
||||
private webStore: plugins.webstore.WebStore<TStatePayload> | null = null;
|
||||
|
||||
private middlewares: TMiddleware<TStatePayload>[] = [];
|
||||
|
||||
// Process tracking
|
||||
private processes: StateProcess<TStatePartName, TStatePayload, any>[] = [];
|
||||
|
||||
// Selector memoization
|
||||
private selectorCache = new WeakMap<Function, plugins.smartrx.rxjs.Observable<any>>();
|
||||
private defaultSelectObservable: plugins.smartrx.rxjs.Observable<TStatePayload> | null = null;
|
||||
|
||||
constructor(nameArg: TStatePartName, webStoreOptionsArg?: plugins.webstore.IWebStoreOptions) {
|
||||
this.name = nameArg;
|
||||
|
||||
// Initialize WebStore if webStoreOptions are provided
|
||||
if (webStoreOptions) {
|
||||
this.webStore = new plugins.webstore.WebStore<TStatePayload>(webStoreOptions);
|
||||
this.initWebStore();
|
||||
if (webStoreOptionsArg) {
|
||||
this.webStoreOptions = webStoreOptionsArg;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* initializes the webstore
|
||||
*/
|
||||
private async initWebStore() {
|
||||
if (this.webStore) {
|
||||
public async init() {
|
||||
if (this.webStoreOptions) {
|
||||
this.webStore = new plugins.webstore.WebStore<TStatePayload>(this.webStoreOptions);
|
||||
await this.webStore.init();
|
||||
const storedState = await this.webStore.get(String(this.name));
|
||||
if (storedState) {
|
||||
if (storedState && this.validateState(storedState)) {
|
||||
this.stateStore = storedState;
|
||||
this.notifyChange();
|
||||
await this.notifyChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,72 +79,177 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
/**
|
||||
* gets the state from the state store
|
||||
*/
|
||||
public getState(): TStatePayload {
|
||||
public getState(): TStatePayload | undefined {
|
||||
return this.stateStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* sets the stateStore to the new state
|
||||
* @param newStateArg
|
||||
* adds a middleware that intercepts setState calls.
|
||||
* middleware can transform the state or throw to reject it.
|
||||
* returns a removal function.
|
||||
*/
|
||||
public async setState(newStateArg: TStatePayload) {
|
||||
this.stateStore = newStateArg;
|
||||
this.notifyChange();
|
||||
|
||||
// Save state to WebStore if initialized
|
||||
if (this.webStore) {
|
||||
await this.webStore.set(String(this.name), newStateArg);
|
||||
public addMiddleware(middleware: TMiddleware<TStatePayload>): () => void {
|
||||
this.middlewares.push(middleware);
|
||||
return () => {
|
||||
const idx = this.middlewares.indexOf(middleware);
|
||||
if (idx !== -1) {
|
||||
this.middlewares.splice(idx, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* sets the stateStore to the new state (serialized via mutation queue)
|
||||
*/
|
||||
public async setState(newStateArg: TStatePayload): Promise<TStatePayload> {
|
||||
if (this.disposed) {
|
||||
throw new Error(`StatePart '${this.name}' has been disposed`);
|
||||
}
|
||||
return this.mutationQueue = this.mutationQueue.then(
|
||||
() => this.applyState(newStateArg),
|
||||
() => this.applyState(newStateArg),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* applies the state change (middleware → validate → persist → notify)
|
||||
*/
|
||||
private async applyState(newStateArg: TStatePayload): Promise<TStatePayload> {
|
||||
// Run middleware chain
|
||||
let processedState = newStateArg;
|
||||
for (const mw of this.middlewares) {
|
||||
processedState = await mw(processedState, this.stateStore);
|
||||
}
|
||||
|
||||
// Validate state structure
|
||||
if (!this.validateState(processedState)) {
|
||||
throw new Error(`Invalid state structure for state part '${this.name}'`);
|
||||
}
|
||||
|
||||
// Save to WebStore first to ensure atomicity
|
||||
if (this.webStore) {
|
||||
await this.webStore.set(String(this.name), processedState);
|
||||
}
|
||||
|
||||
// Update in-memory state after successful persistence
|
||||
this.stateStore = processedState;
|
||||
await this.notifyChange();
|
||||
|
||||
return this.stateStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates state structure - can be overridden for custom validation
|
||||
*/
|
||||
protected validateState(stateArg: any): stateArg is TStatePayload {
|
||||
return stateArg !== null && stateArg !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* notifies of a change on the state
|
||||
*/
|
||||
public notifyChange() {
|
||||
const createStateHash = (stateArg: any) => {
|
||||
return plugins.isohash.sha256FromString(plugins.smartjson.stringify(stateArg));
|
||||
};
|
||||
if (
|
||||
this.stateStore &&
|
||||
this.lastStateNotificationPayloadHash &&
|
||||
createStateHash(this.stateStore) === this.lastStateNotificationPayloadHash
|
||||
) {
|
||||
public async notifyChange() {
|
||||
const snapshot = this.stateStore;
|
||||
if (snapshot === undefined) {
|
||||
return;
|
||||
} else {
|
||||
this.lastStateNotificationPayloadHash = this.stateStore;
|
||||
}
|
||||
this.state.next(this.stateStore);
|
||||
|
||||
// If inside a batch, defer the notification
|
||||
if (this.smartstateRef?.isBatching) {
|
||||
this.smartstateRef.registerPendingNotification(this);
|
||||
return;
|
||||
}
|
||||
|
||||
const createStateHash = async (stateArg: any) => {
|
||||
return await plugins.smarthashWeb.sha256FromString(plugins.smartjson.stableOneWayStringify(stateArg));
|
||||
};
|
||||
try {
|
||||
const currentHash = await createStateHash(snapshot);
|
||||
if (
|
||||
this.lastStateNotificationPayloadHash &&
|
||||
currentHash === this.lastStateNotificationPayloadHash
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.lastStateNotificationPayloadHash = currentHash;
|
||||
} catch (err) {
|
||||
console.error(`State hash computation failed for '${this.name}':`, err);
|
||||
}
|
||||
this.state.next(snapshot);
|
||||
}
|
||||
private lastStateNotificationPayloadHash: any;
|
||||
|
||||
/**
|
||||
* creates a cumulative notification by adding a change notification at the end of the call stack;
|
||||
* creates a cumulative notification by adding a change notification at the end of the call stack
|
||||
*/
|
||||
public notifyChangeCumulative() {
|
||||
// TODO: check viability
|
||||
setTimeout(() => this.state.next(this.stateStore), 0);
|
||||
if (this.pendingCumulativeNotification) {
|
||||
clearTimeout(this.pendingCumulativeNotification);
|
||||
}
|
||||
|
||||
this.pendingCumulativeNotification = setTimeout(() => {
|
||||
this.pendingCumulativeNotification = null;
|
||||
if (this.stateStore !== undefined) {
|
||||
this.notifyChange().catch((err) => {
|
||||
console.error(`notifyChangeCumulative failed for '${this.name}':`, err);
|
||||
});
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* selects a state or a substate
|
||||
* selects a state or a substate.
|
||||
* supports an optional AbortSignal for automatic unsubscription.
|
||||
* memoizes observables by selector function reference.
|
||||
*/
|
||||
public select<T = TStatePayload>(
|
||||
selectorFn?: (state: TStatePayload) => T
|
||||
selectorFn?: (state: TStatePayload) => T,
|
||||
options?: { signal?: AbortSignal }
|
||||
): plugins.smartrx.rxjs.Observable<T> {
|
||||
if (!selectorFn) {
|
||||
selectorFn = (state: TStatePayload) => <T>(<any>state);
|
||||
const hasSignal = options?.signal != null;
|
||||
|
||||
// Check memoization cache (only for non-signal selects)
|
||||
if (!hasSignal) {
|
||||
if (!selectorFn) {
|
||||
if (this.defaultSelectObservable) {
|
||||
return this.defaultSelectObservable as unknown as plugins.smartrx.rxjs.Observable<T>;
|
||||
}
|
||||
} else if (this.selectorCache.has(selectorFn)) {
|
||||
return this.selectorCache.get(selectorFn)!;
|
||||
}
|
||||
}
|
||||
const mapped = this.state.pipe(
|
||||
plugins.smartrx.rxjs.ops.startWith(this.getState()),
|
||||
|
||||
const effectiveSelectorFn = selectorFn || ((state: TStatePayload) => <T>(<any>state));
|
||||
const SELECTOR_ERROR: unique symbol = Symbol('selector-error');
|
||||
|
||||
let mapped = this.state.pipe(
|
||||
plugins.smartrx.rxjs.ops.filter((stateArg): stateArg is TStatePayload => stateArg !== undefined),
|
||||
plugins.smartrx.rxjs.ops.map((stateArg) => {
|
||||
try {
|
||||
return selectorFn(stateArg);
|
||||
return effectiveSelectorFn(stateArg);
|
||||
} catch (e) {
|
||||
// Nothing here
|
||||
console.error(`Selector error in state part '${this.name}':`, e);
|
||||
return SELECTOR_ERROR as any;
|
||||
}
|
||||
})
|
||||
}),
|
||||
plugins.smartrx.rxjs.ops.filter((v: any) => v !== SELECTOR_ERROR),
|
||||
distinctUntilChanged((a: any, b: any) => JSON.stringify(a) === JSON.stringify(b)),
|
||||
);
|
||||
return mapped;
|
||||
|
||||
if (hasSignal) {
|
||||
mapped = mapped.pipe(takeUntil(fromAbortSignal(options.signal!)));
|
||||
return mapped;
|
||||
}
|
||||
|
||||
// Apply shareReplay for caching and store in memo cache
|
||||
const shared = mapped.pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
||||
if (!selectorFn) {
|
||||
this.defaultSelectObservable = shared as unknown as plugins.smartrx.rxjs.Observable<TStatePayload>;
|
||||
} else {
|
||||
this.selectorCache.set(selectorFn, shared);
|
||||
}
|
||||
|
||||
return shared;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,32 +262,112 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
}
|
||||
|
||||
/**
|
||||
* dispatches an action on the statepart level
|
||||
* creates a depth-tracked action context for safe nested dispatch.
|
||||
* Using context.dispatch() within an actionDef bypasses the mutation queue
|
||||
* and executes the sub-action inline, preventing deadlocks.
|
||||
*/
|
||||
public async dispatchAction<T>(stateAction: StateAction<TStatePayload, T>, actionPayload: T): Promise<TStatePayload> {
|
||||
await this.cumulativeDeferred.promise;
|
||||
const newState = await stateAction.actionDef(this, actionPayload);
|
||||
await this.setState(newState);
|
||||
return this.getState();
|
||||
private createActionContext(depth: number): IActionContext<TStatePayload> {
|
||||
const self = this;
|
||||
return {
|
||||
dispatch: async <U>(action: StateAction<TStatePayload, U>, payload: U): Promise<TStatePayload> => {
|
||||
if (depth >= StatePart.MAX_NESTED_DISPATCH_DEPTH) {
|
||||
throw new Error(
|
||||
`Maximum nested action dispatch depth (${StatePart.MAX_NESTED_DISPATCH_DEPTH}) exceeded. ` +
|
||||
`Check for circular action dispatches.`
|
||||
);
|
||||
}
|
||||
const innerContext = self.createActionContext(depth + 1);
|
||||
const newState = await action.actionDef(self, payload, innerContext);
|
||||
return self.applyState(newState);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* waits until a certain part of the state becomes available
|
||||
* @param selectorFn
|
||||
* dispatches an action on the statepart level
|
||||
*/
|
||||
public async dispatchAction<T>(stateAction: StateAction<TStatePayload, T>, actionPayload: T): Promise<TStatePayload> {
|
||||
if (this.disposed) {
|
||||
throw new Error(`StatePart '${this.name}' has been disposed`);
|
||||
}
|
||||
await this.cumulativeDeferred.promise;
|
||||
const execute = async () => {
|
||||
const context = this.createActionContext(0);
|
||||
const newState = await stateAction.actionDef(this, actionPayload, context);
|
||||
return this.applyState(newState);
|
||||
};
|
||||
return this.mutationQueue = this.mutationQueue.then(execute, execute);
|
||||
}
|
||||
|
||||
/**
|
||||
* waits until a certain part of the state becomes available.
|
||||
* supports optional timeout and AbortSignal.
|
||||
*/
|
||||
public async waitUntilPresent<T = TStatePayload>(
|
||||
selectorFn?: (state: TStatePayload) => T
|
||||
selectorFn?: (state: TStatePayload) => T,
|
||||
optionsOrTimeout?: number | { timeoutMs?: number; signal?: AbortSignal }
|
||||
): Promise<T> {
|
||||
// Parse backward-compatible args
|
||||
let timeoutMs: number | undefined;
|
||||
let signal: AbortSignal | undefined;
|
||||
if (typeof optionsOrTimeout === 'number') {
|
||||
timeoutMs = optionsOrTimeout;
|
||||
} else if (optionsOrTimeout) {
|
||||
timeoutMs = optionsOrTimeout.timeoutMs;
|
||||
signal = optionsOrTimeout.signal;
|
||||
}
|
||||
|
||||
const done = plugins.smartpromise.defer<T>();
|
||||
const selectedObservable = this.select(selectorFn);
|
||||
const subscription = selectedObservable.subscribe(async (value) => {
|
||||
if (value) {
|
||||
let resolved = false;
|
||||
|
||||
// Check if already aborted
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Aborted');
|
||||
}
|
||||
|
||||
const subscription = selectedObservable.subscribe((value) => {
|
||||
if (value !== undefined && value !== null && !resolved) {
|
||||
resolved = true;
|
||||
done.resolve(value);
|
||||
}
|
||||
});
|
||||
const result = await done.promise;
|
||||
subscription.unsubscribe();
|
||||
return result;
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
if (timeoutMs) {
|
||||
timeoutId = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
subscription.unsubscribe();
|
||||
done.reject(new Error(`waitUntilPresent timed out after ${timeoutMs}ms`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
// Handle abort signal
|
||||
const abortHandler = signal ? () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
subscription.unsubscribe();
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
done.reject(new Error('Aborted'));
|
||||
}
|
||||
} : undefined;
|
||||
|
||||
if (signal && abortHandler) {
|
||||
signal.addEventListener('abort', abortHandler);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await done.promise;
|
||||
return result;
|
||||
} finally {
|
||||
subscription.unsubscribe();
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (signal && abortHandler) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,6 +378,80 @@ export class StatePart<TStatePartName, TStatePayload> {
|
||||
) {
|
||||
const resultPromise = funcArg(this);
|
||||
this.cumulativeDeferred.addPromise(resultPromise);
|
||||
this.setState(await resultPromise);
|
||||
await this.setState(await resultPromise);
|
||||
}
|
||||
|
||||
/**
|
||||
* creates a managed, pausable process that ties an observable producer to state updates
|
||||
*/
|
||||
public createProcess<TProducerValue>(
|
||||
options: IProcessOptions<TStatePayload, TProducerValue>
|
||||
): StateProcess<TStatePartName, TStatePayload, TProducerValue> {
|
||||
if (this.disposed) {
|
||||
throw new Error(`StatePart '${this.name}' has been disposed`);
|
||||
}
|
||||
const process = new StateProcess<TStatePartName, TStatePayload, TProducerValue>(this, options);
|
||||
this.processes.push(process);
|
||||
if (options.autoStart) {
|
||||
process.start();
|
||||
}
|
||||
return process;
|
||||
}
|
||||
|
||||
/**
|
||||
* creates a process that dispatches an action on a recurring interval
|
||||
*/
|
||||
public createScheduledAction<TActionPayload>(
|
||||
options: IScheduledActionOptions<TStatePayload, TActionPayload>
|
||||
): StateProcess<TStatePartName, TStatePayload, number> {
|
||||
if (this.disposed) {
|
||||
throw new Error(`StatePart '${this.name}' has been disposed`);
|
||||
}
|
||||
const process = new StateProcess<TStatePartName, TStatePayload, number>(this, {
|
||||
producer: () => interval(options.intervalMs),
|
||||
sideEffect: async () => {
|
||||
await options.action.trigger(options.payload);
|
||||
},
|
||||
autoPause: options.autoPause ?? false,
|
||||
});
|
||||
this.processes.push(process);
|
||||
process.start();
|
||||
return process;
|
||||
}
|
||||
|
||||
/** @internal — called by StateProcess.dispose() to remove itself */
|
||||
public _removeProcess(process: StateProcess<any, any, any>): void {
|
||||
const idx = this.processes.indexOf(process);
|
||||
if (idx !== -1) {
|
||||
this.processes.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* disposes the state part, completing the Subject and cleaning up resources
|
||||
*/
|
||||
public dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
|
||||
// Dispose all processes first
|
||||
for (const process of [...this.processes]) {
|
||||
process.dispose();
|
||||
}
|
||||
this.processes.length = 0;
|
||||
|
||||
this.state.complete();
|
||||
this.mutationQueue = Promise.resolve() as any;
|
||||
|
||||
if (this.pendingCumulativeNotification) {
|
||||
clearTimeout(this.pendingCumulativeNotification);
|
||||
this.pendingCumulativeNotification = null;
|
||||
}
|
||||
this.middlewares.length = 0;
|
||||
this.selectorCache = new WeakMap();
|
||||
this.defaultSelectObservable = null;
|
||||
this.webStore = null;
|
||||
this.smartstateRef = undefined;
|
||||
this.stateStore = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
177
ts/smartstate.classes.stateprocess.ts
Normal file
177
ts/smartstate.classes.stateprocess.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { BehaviorSubject, Observable, Subscription, of } from 'rxjs';
|
||||
import type { StatePart } from './smartstate.classes.statepart.js';
|
||||
import type { StateAction } from './smartstate.classes.stateaction.js';
|
||||
|
||||
export type TProcessStatus = 'idle' | 'running' | 'paused' | 'disposed';
|
||||
export type TAutoPause = 'visibility' | Observable<boolean> | false;
|
||||
|
||||
export interface IProcessOptions<TStatePayload, TProducerValue> {
|
||||
producer: () => Observable<TProducerValue>;
|
||||
reducer: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
|
||||
autoPause?: TAutoPause;
|
||||
autoStart?: boolean;
|
||||
}
|
||||
|
||||
export interface IScheduledActionOptions<TStatePayload, TActionPayload> {
|
||||
action: StateAction<TStatePayload, TActionPayload>;
|
||||
payload: TActionPayload;
|
||||
intervalMs: number;
|
||||
autoPause?: TAutoPause;
|
||||
}
|
||||
|
||||
/**
|
||||
* creates an Observable<boolean> from the Page Visibility API.
|
||||
* emits true when the page is visible, false when hidden.
|
||||
* in Node.js (no document), returns an always-true observable.
|
||||
*/
|
||||
function createVisibilityObservable(): Observable<boolean> {
|
||||
if (typeof document === 'undefined') {
|
||||
return of(true);
|
||||
}
|
||||
return new Observable<boolean>((subscriber) => {
|
||||
subscriber.next(!document.hidden);
|
||||
const handler = () => subscriber.next(!document.hidden);
|
||||
document.addEventListener('visibilitychange', handler);
|
||||
return () => document.removeEventListener('visibilitychange', handler);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* a managed, pausable process that ties an observable producer to state updates.
|
||||
* supports lifecycle management (start/pause/resume/dispose) and auto-pause signals.
|
||||
*/
|
||||
export class StateProcess<TStatePartName, TStatePayload, TProducerValue> {
|
||||
private readonly statePartRef: StatePart<TStatePartName, TStatePayload>;
|
||||
private readonly producerFn: () => Observable<TProducerValue>;
|
||||
private readonly reducer?: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
|
||||
private readonly sideEffect?: (value: TProducerValue) => Promise<void> | void;
|
||||
private readonly autoPauseOption: TAutoPause;
|
||||
|
||||
private statusSubject = new BehaviorSubject<TProcessStatus>('idle');
|
||||
private producerSubscription: Subscription | null = null;
|
||||
private autoPauseSubscription: Subscription | null = null;
|
||||
private processingQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
statePartRef: StatePart<TStatePartName, TStatePayload>,
|
||||
options: {
|
||||
producer: () => Observable<TProducerValue>;
|
||||
reducer?: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
|
||||
sideEffect?: (value: TProducerValue) => Promise<void> | void;
|
||||
autoPause?: TAutoPause;
|
||||
}
|
||||
) {
|
||||
this.statePartRef = statePartRef;
|
||||
this.producerFn = options.producer;
|
||||
this.reducer = options.reducer;
|
||||
this.sideEffect = options.sideEffect;
|
||||
this.autoPauseOption = options.autoPause ?? false;
|
||||
}
|
||||
|
||||
public get status(): TProcessStatus {
|
||||
return this.statusSubject.getValue();
|
||||
}
|
||||
|
||||
public get status$(): Observable<TProcessStatus> {
|
||||
return this.statusSubject.asObservable();
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
if (this.status === 'disposed') {
|
||||
throw new Error('Cannot start a disposed process');
|
||||
}
|
||||
if (this.status === 'running') return;
|
||||
this.statusSubject.next('running');
|
||||
this.subscribeProducer();
|
||||
this.setupAutoPause();
|
||||
}
|
||||
|
||||
public pause(): void {
|
||||
if (this.status === 'disposed') {
|
||||
throw new Error('Cannot pause a disposed process');
|
||||
}
|
||||
if (this.status !== 'running') return;
|
||||
this.statusSubject.next('paused');
|
||||
this.unsubscribeProducer();
|
||||
}
|
||||
|
||||
public resume(): void {
|
||||
if (this.status === 'disposed') {
|
||||
throw new Error('Cannot resume a disposed process');
|
||||
}
|
||||
if (this.status !== 'paused') return;
|
||||
this.statusSubject.next('running');
|
||||
this.subscribeProducer();
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (this.status === 'disposed') return;
|
||||
this.unsubscribeProducer();
|
||||
this.teardownAutoPause();
|
||||
this.statusSubject.next('disposed');
|
||||
this.statusSubject.complete();
|
||||
this.statePartRef._removeProcess(this);
|
||||
}
|
||||
|
||||
private subscribeProducer(): void {
|
||||
this.unsubscribeProducer();
|
||||
const source = this.producerFn();
|
||||
this.producerSubscription = source.subscribe({
|
||||
next: (value) => {
|
||||
// Queue value processing to ensure each reads fresh state after the previous completes
|
||||
this.processingQueue = this.processingQueue.then(async () => {
|
||||
try {
|
||||
if (this.sideEffect) {
|
||||
await this.sideEffect(value);
|
||||
} else if (this.reducer) {
|
||||
const currentState = this.statePartRef.getState();
|
||||
if (currentState !== undefined) {
|
||||
await this.statePartRef.setState(this.reducer(currentState, value));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('StateProcess value handling error:', err);
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('StateProcess producer error:', err);
|
||||
if (this.status === 'running') {
|
||||
this.statusSubject.next('paused');
|
||||
this.unsubscribeProducer();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private unsubscribeProducer(): void {
|
||||
if (this.producerSubscription) {
|
||||
this.producerSubscription.unsubscribe();
|
||||
this.producerSubscription = null;
|
||||
}
|
||||
}
|
||||
|
||||
private setupAutoPause(): void {
|
||||
this.teardownAutoPause();
|
||||
if (!this.autoPauseOption) return;
|
||||
|
||||
const signal$ = this.autoPauseOption === 'visibility'
|
||||
? createVisibilityObservable()
|
||||
: this.autoPauseOption;
|
||||
|
||||
this.autoPauseSubscription = signal$.subscribe((active) => {
|
||||
if (!active && this.status === 'running') {
|
||||
this.pause();
|
||||
} else if (active && this.status === 'paused') {
|
||||
this.resume();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private teardownAutoPause(): void {
|
||||
if (this.autoPauseSubscription) {
|
||||
this.autoPauseSubscription.unsubscribe();
|
||||
this.autoPauseSubscription = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
61
ts/smartstate.contextprovider.ts
Normal file
61
ts/smartstate.contextprovider.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { StatePart } from './smartstate.classes.statepart.js';
|
||||
|
||||
export interface IContextProviderOptions<TPayload> {
|
||||
/** the context key (compared by strict equality) */
|
||||
context: unknown;
|
||||
/** the state part to provide */
|
||||
statePart: StatePart<any, TPayload>;
|
||||
/** optional selector to provide a derived value instead of the full state */
|
||||
selectorFn?: (state: TPayload) => any;
|
||||
}
|
||||
|
||||
/**
|
||||
* attaches a Context Protocol provider to an HTML element.
|
||||
* listens for `context-request` events and responds with the state part's value.
|
||||
* if subscribe=true, retains the callback and invokes it on every state change.
|
||||
* returns a cleanup function that removes the listener and unsubscribes.
|
||||
*/
|
||||
export function attachContextProvider<TPayload>(
|
||||
element: HTMLElement,
|
||||
options: IContextProviderOptions<TPayload>,
|
||||
): () => void {
|
||||
const { context, statePart, selectorFn } = options;
|
||||
const subscribers = new Set<(value: any, unsubscribe?: () => void) => void>();
|
||||
|
||||
const subscription = statePart.select(selectorFn).subscribe((value) => {
|
||||
for (const cb of subscribers) {
|
||||
cb(value);
|
||||
}
|
||||
});
|
||||
|
||||
const getValue = (): any => {
|
||||
const state = statePart.getState();
|
||||
if (state === undefined) return undefined;
|
||||
return selectorFn ? selectorFn(state) : state;
|
||||
};
|
||||
|
||||
const handler = (event: Event) => {
|
||||
const e = event as CustomEvent;
|
||||
const detail = e.detail;
|
||||
if (!detail || detail.context !== context) return;
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
if (detail.subscribe) {
|
||||
const cb = detail.callback;
|
||||
subscribers.add(cb);
|
||||
const unsubscribe = () => subscribers.delete(cb);
|
||||
cb(getValue(), unsubscribe);
|
||||
} else {
|
||||
detail.callback(getValue());
|
||||
}
|
||||
};
|
||||
|
||||
element.addEventListener('context-request', handler);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('context-request', handler);
|
||||
subscription.unsubscribe();
|
||||
subscribers.clear();
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as isohash from '@push.rocks/isohash';
|
||||
import * as smarthashWeb from '@push.rocks/smarthash/web';
|
||||
import * as smartjson from '@push.rocks/smartjson';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import * as webstore from '@push.rocks/webstore';
|
||||
|
||||
export { isohash, smartjson, smartpromise, smartrx, webstore };
|
||||
export { smarthashWeb, smartjson, smartpromise, smartrx, webstore };
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": [
|
||||
"dist_*/**/*.d.ts"
|
||||
|
||||
Reference in New Issue
Block a user