├── .github └── workflows │ ├── ci.yml │ ├── plan-release.yml │ └── publish.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .release-plan.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── RELEASE.md ├── package.json ├── pnpm-lock.yaml ├── src ├── computed.ts ├── equality.ts ├── errors.ts ├── graph.ts ├── index.ts ├── public-api-types.ts ├── signal.ts └── wrapper.ts ├── tests ├── Signal │ ├── computed.test.ts │ ├── ported │ │ ├── preact.test.ts │ │ └── vue.test.ts │ ├── state.test.ts │ └── subtle │ │ ├── currentComputed.test.ts │ │ ├── untrack.test.ts │ │ ├── watch-unwatch.test.ts │ │ └── watcher.test.ts ├── behaviors │ ├── custom-equality.test.ts │ ├── cycles.test.ts │ ├── dynamic-dependencies.test.ts │ ├── errors.test.ts │ ├── graph.test.ts │ ├── guards.test.ts │ ├── liveness.test.ts │ ├── prohibited-contexts.test.ts │ ├── pruning.test.ts │ ├── receivers.test.ts │ └── type-checking.test.ts └── benchmarks │ ├── adapter.ts │ └── benchmarks.ts ├── tsconfig.json └── vite.config.ts /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: {} 8 | 9 | concurrency: 10 | group: ci-${{ github.head_ref || github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | test: 15 | name: 'Test ${{ matrix.testenv.name }}' 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 5 18 | strategy: 19 | matrix: 20 | testenv: 21 | - {name: 'Node', args: ''} 22 | - {name: 'Chrome', args: '--browser.name=chrome --browser.headless'} 23 | - {name: 'Firefox', args: '--browser.name=firefox --browser.headless'} 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: wyvox/action-setup-pnpm@v3 28 | - run: pnpm install --no-lockfile 29 | - run: pnpm lint 30 | - run: pnpm build 31 | - run: pnpm vitest ${{ matrix.testenv.args }} 32 | 33 | benchmarks: 34 | runs-on: ubuntu-latest 35 | timeout-minutes: 10 36 | permissions: 37 | contents: write 38 | pull-requests: write 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: wyvox/action-setup-pnpm@v3 42 | - run: pnpm install 43 | - run: pnpm benchmarks 44 | - uses: benchmark-action/github-action-benchmark@d48d326b4ca9ba73ca0cd0d59f108f9e02a381c7 #v1.20.4 45 | with: 46 | name: Benchmarks 47 | tool: 'customSmallerIsBetter' 48 | output-file-path: benchmarks.json 49 | auto-push: ${{ github.event_name == 'push' }} 50 | github-token: ${{ secrets.GITHUB_TOKEN }} 51 | comment-on-alert: true 52 | summary-always: true 53 | 54 | ## 55 | # NOTE: in ration is <1, the performance has improved. 56 | - uses: mshick/add-pr-comment@v2 57 | with: 58 | message: | 59 | ## Benchmarks[^note] 60 | 61 | Results for the latest run for this branch: 62 | 63 | https://github.com/proposal-signals/signal-polyfill/actions/runs/${{ github.run_id }} 64 | 65 | --------------- 66 | 67 | Compare with `main` over time: 68 | 69 | https://proposal-signals.github.io/signal-polyfill/dev/bench/ 70 | 71 | [^note]: When the ratio is < 1, performance has improved in the PR. 72 | -------------------------------------------------------------------------------- /.github/workflows/plan-release.yml: -------------------------------------------------------------------------------- 1 | name: Release Plan Review 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - master 7 | pull_request: 8 | types: 9 | - labeled 10 | 11 | concurrency: 12 | group: plan-release # only the latest one of these should ever be running 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | check-plan: 17 | name: 'Check Release Plan' 18 | runs-on: ubuntu-latest 19 | outputs: 20 | command: ${{ steps.check-release.outputs.command }} 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | ref: 'main' 27 | # This will only cause the `check-plan` job to have a "command" of `release` 28 | # when the .release-plan.json file was changed on the last commit. 29 | - id: check-release 30 | run: if git diff --name-only HEAD HEAD~1 | grep -w -q ".release-plan.json"; then echo "command=release"; fi >> $GITHUB_OUTPUT 31 | 32 | prepare_release_notes: 33 | name: Prepare Release Notes 34 | runs-on: ubuntu-latest 35 | timeout-minutes: 5 36 | needs: check-plan 37 | permissions: 38 | contents: write 39 | pull-requests: write 40 | outputs: 41 | explanation: ${{ steps.explanation.outputs.text }} 42 | # only run on push event if plan wasn't updated (don't create a release plan when we're releasing) 43 | # only run on labeled event if the PR has already been merged 44 | if: (github.event_name == 'push' && needs.check-plan.outputs.command != 'release') || (github.event_name == 'pull_request' && github.event.pull_request.merged == true) 45 | 46 | steps: 47 | - uses: actions/checkout@v4 48 | # We need to download lots of history so that 49 | # github-changelog can discover what's changed since the last release 50 | with: 51 | fetch-depth: 0 52 | ref: 'main' 53 | - uses: wyvox/action-setup-pnpm@v3 54 | - run: pnpm install --frozen-lockfile 55 | 56 | - name: 'Generate Explanation and Prep Changelogs' 57 | id: explanation 58 | run: | 59 | set +e 60 | 61 | pnpm release-plan prepare 2> >(tee -a release-plan-stderr.txt >&2) 62 | 63 | 64 | if [ $? -ne 0 ]; then 65 | echo 'text<> $GITHUB_OUTPUT 66 | cat release-plan-stderr.txt >> $GITHUB_OUTPUT 67 | echo 'EOF' >> $GITHUB_OUTPUT 68 | else 69 | echo 'text<> $GITHUB_OUTPUT 70 | jq .description .release-plan.json -r >> $GITHUB_OUTPUT 71 | echo 'EOF' >> $GITHUB_OUTPUT 72 | rm release-plan-stderr.txt 73 | fi 74 | env: 75 | GITHUB_AUTH: ${{ secrets.GITHUB_TOKEN }} 76 | 77 | - uses: peter-evans/create-pull-request@v6 78 | with: 79 | commit-message: "Prepare Release using 'release-plan'" 80 | labels: 'internal' 81 | branch: release-preview 82 | title: Prepare Release 83 | body: | 84 | This PR is a preview of the release that [release-plan](https://github.com/embroider-build/release-plan) has prepared. To release you should just merge this PR 👍 85 | 86 | ----------------------------------------- 87 | 88 | ${{ steps.explanation.outputs.text }} 89 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # For every push to the master branch, this checks if the release-plan was 2 | # updated and if it was it will publish stable npm packages based on the 3 | # release plan 4 | 5 | name: Publish Stable 6 | 7 | on: 8 | workflow_dispatch: 9 | push: 10 | branches: 11 | - main 12 | - master 13 | 14 | concurrency: 15 | group: publish-${{ github.head_ref || github.ref }} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | check-plan: 20 | name: 'Check Release Plan' 21 | runs-on: ubuntu-latest 22 | outputs: 23 | command: ${{ steps.check-release.outputs.command }} 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 0 29 | ref: 'main' 30 | # This will only cause the `check-plan` job to have a result of `success` 31 | # when the .release-plan.json file was changed on the last commit. This 32 | # plus the fact that this action only runs on main will be enough of a guard 33 | - id: check-release 34 | run: if git diff --name-only HEAD HEAD~1 | grep -w -q ".release-plan.json"; then echo "command=release"; fi >> $GITHUB_OUTPUT 35 | 36 | publish: 37 | name: 'NPM Publish' 38 | runs-on: ubuntu-latest 39 | needs: check-plan 40 | if: needs.check-plan.outputs.command == 'release' 41 | permissions: 42 | contents: write 43 | pull-requests: write 44 | 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: wyvox/action-setup-pnpm@v3 48 | with: 49 | # This creates an .npmrc that reads the NODE_AUTH_TOKEN environment variable 50 | node-registry-url: 'https://registry.npmjs.org' 51 | - run: pnpm install --frozen-lockfile 52 | - name: npm publish 53 | run: pnpm release-plan publish 54 | 55 | env: 56 | GITHUB_AUTH: ${{ secrets.GITHUB_TOKEN }} 57 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # Only apps should have lockfiles 40 | yarn.lock 41 | package-lock.json 42 | npm-shrinkwrap.json 43 | pnpm-lock.yaml 44 | 45 | # Build directory 46 | build 47 | dist/ 48 | *.tsbuildinfo 49 | .DS_Store 50 | 51 | # Benchmarks result: 52 | benchmarks.json 53 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md 2 | *.yaml 3 | *.yml 4 | node_modules/ 5 | dist/ 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "embeddedLanguageFormatting": "off", 6 | "singleQuote": true, 7 | "semi": true, 8 | "quoteProps": "preserve", 9 | "bracketSpacing": false 10 | } 11 | -------------------------------------------------------------------------------- /.release-plan.json: -------------------------------------------------------------------------------- 1 | { 2 | "solution": { 3 | "signal-polyfill": { 4 | "impact": "patch", 5 | "oldVersion": "0.2.1", 6 | "newVersion": "0.2.2", 7 | "constraints": [ 8 | { 9 | "impact": "patch", 10 | "reason": "Appears in changelog section :bug: Bug Fix" 11 | }, 12 | { 13 | "impact": "patch", 14 | "reason": "Appears in changelog section :house: Internal" 15 | } 16 | ], 17 | "pkgJSONPath": "./package.json" 18 | } 19 | }, 20 | "description": "## Release (2025-01-17)\n\nsignal-polyfill 0.2.2 (patch)\n\n#### :bug: Bug Fix\n* `signal-polyfill`\n * [#42](https://github.com/proposal-signals/signal-polyfill/pull/42) fix assignment of subtypes (#7) ([@Gvozd](https://github.com/Gvozd))\n * [#45](https://github.com/proposal-signals/signal-polyfill/pull/45) fix isState / isComputed guards to allow all parameter types ([@fcrozatier](https://github.com/fcrozatier))\n\n#### :house: Internal\n* `signal-polyfill`\n * [#37](https://github.com/proposal-signals/signal-polyfill/pull/37) Add type-tests to ensure consistent public API expectations ([@NullVoxPopuli](https://github.com/NullVoxPopuli))\n\n#### Committers: 3\n- Frédéric Crozatier ([@fcrozatier](https://github.com/fcrozatier))\n- Gvozd ([@Gvozd](https://github.com/Gvozd))\n- [@NullVoxPopuli](https://github.com/NullVoxPopuli)\n" 21 | } 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Release (2025-01-17) 4 | 5 | signal-polyfill 0.2.2 (patch) 6 | 7 | #### :bug: Bug Fix 8 | * `signal-polyfill` 9 | * [#42](https://github.com/proposal-signals/signal-polyfill/pull/42) fix assignment of subtypes (#7) ([@Gvozd](https://github.com/Gvozd)) 10 | * [#45](https://github.com/proposal-signals/signal-polyfill/pull/45) fix isState / isComputed guards to allow all parameter types ([@fcrozatier](https://github.com/fcrozatier)) 11 | 12 | #### :house: Internal 13 | * `signal-polyfill` 14 | * [#37](https://github.com/proposal-signals/signal-polyfill/pull/37) Add type-tests to ensure consistent public API expectations ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 15 | 16 | #### Committers: 3 17 | - Frédéric Crozatier ([@fcrozatier](https://github.com/fcrozatier)) 18 | - Gvozd ([@Gvozd](https://github.com/Gvozd)) 19 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 20 | 21 | ## Release (2024-10-09) 22 | 23 | signal-polyfill 0.2.1 (patch) 24 | 25 | #### :bug: Bug Fix 26 | * `signal-polyfill` 27 | * [#35](https://github.com/proposal-signals/signal-polyfill/pull/35) Export isState, isComputed, isWatcher checks ([@EvanCzako](https://github.com/EvanCzako)) 28 | 29 | #### Committers: 1 30 | - Evan Czako ([@EvanCzako](https://github.com/EvanCzako)) 31 | 32 | ## Release (2024-10-01) 33 | 34 | signal-polyfill 0.2.0 (minor) 35 | 36 | #### :rocket: Enhancement 37 | * `signal-polyfill` 38 | * [#18](https://github.com/proposal-signals/signal-polyfill/pull/18) Use prepare script allowing usage from git npm dependency ([@divdavem](https://github.com/divdavem)) 39 | 40 | #### :bug: Bug Fix 41 | * `signal-polyfill` 42 | * [#16](https://github.com/proposal-signals/signal-polyfill/pull/16) fix: it should not break a computed signal to watch it before getting its value ([@divdavem](https://github.com/divdavem)) 43 | 44 | #### :house: Internal 45 | * `signal-polyfill` 46 | * [#29](https://github.com/proposal-signals/signal-polyfill/pull/29) Add prettierignore ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 47 | 48 | #### Committers: 2 49 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 50 | - [@divdavem](https://github.com/divdavem) 51 | 52 | ## Release (2024-07-23) 53 | 54 | signal-polyfill 0.1.2 (patch) 55 | 56 | #### :bug: Bug Fix 57 | * `signal-polyfill` 58 | * [#26](https://github.com/proposal-signals/signal-polyfill/pull/26) Fix unwatch multiple signals ([@tuhm1](https://github.com/tuhm1)) 59 | * [#21](https://github.com/proposal-signals/signal-polyfill/pull/21) Turn off minify for release bundle ([@ds300](https://github.com/ds300)) 60 | 61 | #### :memo: Documentation 62 | * `signal-polyfill` 63 | * [#13](https://github.com/proposal-signals/signal-polyfill/pull/13) improve effects example slightly ([@benlesh](https://github.com/benlesh)) 64 | 65 | #### :house: Internal 66 | * `signal-polyfill` 67 | * [#20](https://github.com/proposal-signals/signal-polyfill/pull/20) Set up prettier ([@ds300](https://github.com/ds300)) 68 | * [#11](https://github.com/proposal-signals/signal-polyfill/pull/11) Reorganize and split tests ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 69 | 70 | #### Committers: 4 71 | - Ben Lesh ([@benlesh](https://github.com/benlesh)) 72 | - David Sheldrick ([@ds300](https://github.com/ds300)) 73 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 74 | - [@tuhm1](https://github.com/tuhm1) 75 | 76 | ## Release (2024-05-14) 77 | 78 | signal-polyfill 0.1.1 (patch) 79 | 80 | #### :house: Internal 81 | 82 | - `signal-polyfill` 83 | - [#6](https://github.com/proposal-signals/signal-polyfill/pull/6) Fix repo references ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 84 | - [#4](https://github.com/proposal-signals/signal-polyfill/pull/4) Setup release automation ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 85 | - [#1](https://github.com/proposal-signals/signal-polyfill/pull/1) Fix CI ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 86 | 87 | #### Committers: 1 88 | 89 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 90 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Signal Polyfill 2 | 3 | ## ⚠️ This polyfill is a preview of an in-progress proposal and could change at any time. Do not use this in production. ⚠️ 4 | 5 | A "signal" is [a proposed first-class JavaScript data type](https://github.com/tc39/proposal-signals) that enables one-way data flow through cells of state or computations derived from other state/computations. 6 | 7 | This is a polyfill for the `Signal` API. 8 | 9 | ## Examples 10 | 11 | ### Using signals 12 | 13 | - Use `Signal.State(value)` to create a single "cell" of data that can flow through the unidirectional state graph. 14 | - Use `Signal.Computed(callback)` to define a computation based on state or other computations flowing through the graph. 15 | 16 | ```js 17 | import { Signal } from "signal-polyfill"; 18 | import { effect } from "./effect.js"; 19 | 20 | const counter = new Signal.State(0); 21 | const isEven = new Signal.Computed(() => (counter.get() & 1) == 0); 22 | const parity = new Signal.Computed(() => (isEven.get() ? "even" : "odd")); 23 | 24 | effect(() => console.log(parity.get())); // Console logs "even" immediately. 25 | setInterval(() => counter.set(counter.get() + 1), 1000); // Changes the counter every 1000ms. 26 | 27 | // effect triggers console log "odd" 28 | // effect triggers console log "even" 29 | // effect triggers console log "odd" 30 | // ... 31 | ``` 32 | 33 | The signal proposal does not include an `effect` API, since such APIs are often deeply integrated with rendering and batch strategies that are highly framework/library dependent. However, the proposal does seek to define a set of primitives that library authors can use to implement their own effects. 34 | 35 | When working directly with library effect APIs, always be sure to understand the behavior of the `effect` implementation. While the signal algorithm is standardized, effects are not and may vary. To illustrate this, have a look at this code: 36 | 37 | ```js 38 | counter.get(); // 0 39 | effect(() => counter.set(counter.get() + 1)); // Infinite loop??? 40 | counter.get(); // 1 41 | ``` 42 | 43 | Depending on how the effect is implemented, the above code could result in an infinite loop. It's also important to note that running the effect, in this case, causes an immediate invocation of the callback, changing the value of the counter. 44 | 45 | ### Creating a simple effect 46 | 47 | - You can use `Signal.subtle.Watch(callback)` combined with `Signal.Computed(callback)` to create a simple _effect_ implementation. 48 | - The `Signal.subtle.Watch` `callback` is invoked synchronously when a watched signal becomes dirty. 49 | - To batch effect updates, library authors are expected to implement their own schedulers. 50 | - Use `Signal.subtle.Watch#getPending()` to retrieve an array of dirty signals. 51 | - Calling `Signal.subtle.Watch#watch()` with no arguments will re-watch the list of tracked signals again. 52 | 53 | ```js 54 | import { Signal } from "signal-polyfill"; 55 | 56 | let needsEnqueue = true; 57 | 58 | const w = new Signal.subtle.Watcher(() => { 59 | if (needsEnqueue) { 60 | needsEnqueue = false; 61 | queueMicrotask(processPending); 62 | } 63 | }); 64 | 65 | function processPending() { 66 | needsEnqueue = true; 67 | 68 | for (const s of w.getPending()) { 69 | s.get(); 70 | } 71 | 72 | w.watch(); 73 | } 74 | 75 | export function effect(callback) { 76 | let cleanup; 77 | 78 | const computed = new Signal.Computed(() => { 79 | typeof cleanup === "function" && cleanup(); 80 | cleanup = callback(); 81 | }); 82 | 83 | w.watch(computed); 84 | computed.get(); 85 | 86 | return () => { 87 | w.unwatch(computed); 88 | typeof cleanup === "function" && cleanup(); 89 | cleanup = undefined; 90 | }; 91 | } 92 | ``` 93 | 94 | > [!IMPORTANT] 95 | > The `Signal.subtle` APIs are so named in order to communicate that their correct use requires careful attention to detail. These APIs are not targeted at application-level code, but rather at framework/library authors. 96 | 97 | ### Combining signals and decorators 98 | 99 | A class accessor decorator can be combined with the `Signal.State()` API to enable improved DX. 100 | 101 | ```js 102 | import { Signal } from "signal-polyfill"; 103 | 104 | export function signal(target) { 105 | const { get } = target; 106 | 107 | return { 108 | get() { 109 | return get.call(this).get(); 110 | }, 111 | 112 | set(value) { 113 | get.call(this).set(value); 114 | }, 115 | 116 | init(value) { 117 | return new Signal.State(value); 118 | }, 119 | }; 120 | } 121 | ``` 122 | 123 | The above decorator can be used on public or **private** accessors, enabling reactivity while carefully controlling state mutations. 124 | 125 | ```js 126 | export class Counter { 127 | @signal accessor #value = 0; 128 | 129 | get value() { 130 | return this.#value; 131 | } 132 | 133 | increment() { 134 | this.#value++; 135 | } 136 | 137 | decrement() { 138 | if (this.#value > 0) { 139 | this.#value--; 140 | } 141 | } 142 | } 143 | ``` 144 | 145 | ## Contributing 146 | 147 | - clone the repo, `git clone git@github.com:proposal-signals/signal-polyfill.git` 148 | - `cd signal-polyfill` 149 | - use your favorite package manager to install dependencies, e.g.: `pnpm install`, `npm install`, `yarn install`, etc 150 | - make your change 151 | - add and run tests 152 | - open a PR 153 | - collaborate 🥳 154 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | Releases in this repo are mostly automated using [release-plan](https://github.com/embroider-build/release-plan/). Once you label all your PRs correctly (see below) you will have an automatically generated PR that updates your CHANGELOG.md file and a `.release-plan.json` that is used to prepare the release once the PR is merged. 4 | 5 | ## Preparation 6 | 7 | Since the majority of the actual release process is automated, the remaining tasks before releasing are: 8 | 9 | - correctly labeling **all** pull requests that have been merged since the last release 10 | - updating pull request titles so they make sense to our users 11 | 12 | Some great information on why this is important can be found at [keepachangelog.com](https://keepachangelog.com/en/1.1.0/), but the overall 13 | guiding principle here is that changelogs are for humans, not machines. 14 | 15 | When reviewing merged PR's the labels to be used are: 16 | 17 | - breaking - Used when the PR is considered a breaking change. 18 | - enhancement - Used when the PR adds a new feature or enhancement. 19 | - bug - Used when the PR fixes a bug included in a previous release. 20 | - documentation - Used when the PR adds or updates documentation. 21 | - internal - Internal changes or things that don't fit in any other category. 22 | 23 | **Note:** `release-plan` requires that **all** PRs are labeled. If a PR doesn't fit in a category it's fine to label it as `internal` 24 | 25 | ## Release 26 | 27 | Once the prep work is completed, the actual release is straight forward: you just need to merge the open [Plan Release](https://github.com/proposal-signals/signal-polyfill/pulls?q=is%3Apr+is%3Aopen+%22Prepare+Release%22+in%3Atitle) PR 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "signal-polyfill", 3 | "version": "0.2.2", 4 | "description": "A polyfill for the TC39 Signal proposal.", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/proposal-signals/signal-polyfill.git" 8 | }, 9 | "license": "Apache-2.0", 10 | "author": "EisenbergEffect", 11 | "contributors": [ 12 | "Google LLC", 13 | "Bloomberg Finance L.P.", 14 | "EisenbergEffect" 15 | ], 16 | "type": "module", 17 | "main": "dist/index.js", 18 | "types": "dist/index.d.ts", 19 | "scripts": { 20 | "build": "vite build", 21 | "dev": "vite", 22 | "prepare": "npm run build", 23 | "watch:types": "tsc --noEmit --watch", 24 | "lint": "concurrently 'npm:lint:*(!fix)' --names 'lint:' --prefixColors=auto", 25 | "lint:types": "tsc --noEmit", 26 | "lint:prettier": "prettier --check .", 27 | "lint:fix": "prettier --write .", 28 | "test": "vitest", 29 | "benchmarks": "esbuild tests/benchmarks/benchmarks.ts --bundle --format=esm --platform=node --outdir=build --sourcemap=external && node --expose-gc ./build/benchmarks.js" 30 | }, 31 | "devDependencies": { 32 | "esbuild": "^0.25.0", 33 | "js-reactivity-benchmark": "divdavem/js-reactivity-benchmark#77a55ade586a1aac5a67265a4892ff9ae7902500", 34 | "@types/node": "^20.11.25", 35 | "@vitest/browser": "^1.5.3", 36 | "concurrently": "^9.0.1", 37 | "expect-type": "^1.0.0", 38 | "prettier": "^3.2.5", 39 | "release-plan": "^0.9.0", 40 | "typescript": "latest", 41 | "vite": "^5.2.6", 42 | "vite-plugin-dts": "^3.7.3", 43 | "vitest": "^1.4.0", 44 | "webdriverio": "^8.36.1" 45 | }, 46 | "volta": { 47 | "node": "22.0.0", 48 | "pnpm": "9.0.6" 49 | }, 50 | "packageManager": "pnpm@9.0.6" 51 | } 52 | -------------------------------------------------------------------------------- /src/computed.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | import {defaultEquals, ValueEqualityComparer} from './equality.js'; 10 | import { 11 | consumerAfterComputation, 12 | consumerBeforeComputation, 13 | producerAccessed, 14 | producerUpdateValueVersion, 15 | REACTIVE_NODE, 16 | ReactiveNode, 17 | SIGNAL, 18 | } from './graph.js'; 19 | 20 | /** 21 | * A computation, which derives a value from a declarative reactive expression. 22 | * 23 | * `Computed`s are both producers and consumers of reactivity. 24 | */ 25 | export interface ComputedNode extends ReactiveNode, ValueEqualityComparer { 26 | /** 27 | * Current value of the computation, or one of the sentinel values above (`UNSET`, `COMPUTING`, 28 | * `ERROR`). 29 | */ 30 | value: T; 31 | 32 | /** 33 | * If `value` is `ERRORED`, the error caught from the last computation attempt which will 34 | * be re-thrown. 35 | */ 36 | error: unknown; 37 | 38 | /** 39 | * The computation function which will produce a new value. 40 | */ 41 | computation: () => T; 42 | } 43 | 44 | export type ComputedGetter = (() => T) & { 45 | [SIGNAL]: ComputedNode; 46 | }; 47 | 48 | export function computedGet(node: ComputedNode) { 49 | // Check if the value needs updating before returning it. 50 | producerUpdateValueVersion(node); 51 | 52 | // Record that someone looked at this signal. 53 | producerAccessed(node); 54 | 55 | if (node.value === ERRORED) { 56 | throw node.error; 57 | } 58 | 59 | return node.value; 60 | } 61 | 62 | /** 63 | * Create a computed signal which derives a reactive value from an expression. 64 | */ 65 | export function createComputed(computation: () => T): ComputedGetter { 66 | const node: ComputedNode = Object.create(COMPUTED_NODE); 67 | node.computation = computation; 68 | 69 | const computed = () => computedGet(node); 70 | (computed as ComputedGetter)[SIGNAL] = node; 71 | return computed as unknown as ComputedGetter; 72 | } 73 | 74 | /** 75 | * A dedicated symbol used before a computed value has been calculated for the first time. 76 | * Explicitly typed as `any` so we can use it as signal's value. 77 | */ 78 | const UNSET: any = /* @__PURE__ */ Symbol('UNSET'); 79 | 80 | /** 81 | * A dedicated symbol used in place of a computed signal value to indicate that a given computation 82 | * is in progress. Used to detect cycles in computation chains. 83 | * Explicitly typed as `any` so we can use it as signal's value. 84 | */ 85 | const COMPUTING: any = /* @__PURE__ */ Symbol('COMPUTING'); 86 | 87 | /** 88 | * A dedicated symbol used in place of a computed signal value to indicate that a given computation 89 | * failed. The thrown error is cached until the computation gets dirty again. 90 | * Explicitly typed as `any` so we can use it as signal's value. 91 | */ 92 | const ERRORED: any = /* @__PURE__ */ Symbol('ERRORED'); 93 | 94 | // Note: Using an IIFE here to ensure that the spread assignment is not considered 95 | // a side-effect, ending up preserving `COMPUTED_NODE` and `REACTIVE_NODE`. 96 | // TODO: remove when https://github.com/evanw/esbuild/issues/3392 is resolved. 97 | const COMPUTED_NODE = /* @__PURE__ */ (() => { 98 | return { 99 | ...REACTIVE_NODE, 100 | value: UNSET, 101 | dirty: true, 102 | error: null, 103 | equal: defaultEquals, 104 | 105 | producerMustRecompute(node: ComputedNode): boolean { 106 | // Force a recomputation if there's no current value, or if the current value is in the 107 | // process of being calculated (which should throw an error). 108 | return node.value === UNSET || node.value === COMPUTING; 109 | }, 110 | 111 | producerRecomputeValue(node: ComputedNode): void { 112 | if (node.value === COMPUTING) { 113 | // Our computation somehow led to a cyclic read of itself. 114 | throw new Error('Detected cycle in computations.'); 115 | } 116 | 117 | const oldValue = node.value; 118 | node.value = COMPUTING; 119 | 120 | const prevConsumer = consumerBeforeComputation(node); 121 | let newValue: unknown; 122 | let wasEqual = false; 123 | try { 124 | newValue = node.computation.call(node.wrapper); 125 | const oldOk = oldValue !== UNSET && oldValue !== ERRORED; 126 | wasEqual = oldOk && node.equal.call(node.wrapper, oldValue, newValue); 127 | } catch (err) { 128 | newValue = ERRORED; 129 | node.error = err; 130 | } finally { 131 | consumerAfterComputation(node, prevConsumer); 132 | } 133 | 134 | if (wasEqual) { 135 | // No change to `valueVersion` - old and new values are 136 | // semantically equivalent. 137 | node.value = oldValue; 138 | return; 139 | } 140 | 141 | node.value = newValue; 142 | node.version++; 143 | }, 144 | }; 145 | })(); 146 | -------------------------------------------------------------------------------- /src/equality.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | /** 10 | * An interface representing a comparison strategy to determine if two values are equal. 11 | * 12 | * @template T The type of the values to be compared. 13 | */ 14 | export interface ValueEqualityComparer { 15 | equal(a: T, b: T): boolean; 16 | } 17 | 18 | /** 19 | * The default equality function used for `signal` and `computed`, which uses referential equality. 20 | */ 21 | export function defaultEquals(a: T, b: T) { 22 | return Object.is(a, b); 23 | } 24 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | function defaultThrowError(): never { 10 | throw new Error(); 11 | } 12 | 13 | let throwInvalidWriteToSignalErrorFn = defaultThrowError; 14 | 15 | export function throwInvalidWriteToSignalError() { 16 | throwInvalidWriteToSignalErrorFn(); 17 | } 18 | 19 | export function setThrowInvalidWriteToSignalError(fn: () => never): void { 20 | throwInvalidWriteToSignalErrorFn = fn; 21 | } 22 | -------------------------------------------------------------------------------- /src/graph.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | // Required as the signals library is in a separate package, so we need to explicitly ensure the 10 | // global `ngDevMode` type is defined. 11 | declare const ngDevMode: boolean | undefined; 12 | 13 | /** 14 | * The currently active consumer `ReactiveNode`, if running code in a reactive context. 15 | * 16 | * Change this via `setActiveConsumer`. 17 | */ 18 | let activeConsumer: ReactiveNode | null = null; 19 | let inNotificationPhase = false; 20 | 21 | type Version = number & {__brand: 'Version'}; 22 | 23 | /** 24 | * Global epoch counter. Incremented whenever a source signal is set. 25 | */ 26 | let epoch: Version = 1 as Version; 27 | 28 | /** 29 | * Symbol used to tell `Signal`s apart from other functions. 30 | * 31 | * This can be used to auto-unwrap signals in various cases, or to auto-wrap non-signal values. 32 | */ 33 | export const SIGNAL = /* @__PURE__ */ Symbol('SIGNAL'); 34 | 35 | export function setActiveConsumer(consumer: ReactiveNode | null): ReactiveNode | null { 36 | const prev = activeConsumer; 37 | activeConsumer = consumer; 38 | return prev; 39 | } 40 | 41 | export function getActiveConsumer(): ReactiveNode | null { 42 | return activeConsumer; 43 | } 44 | 45 | export function isInNotificationPhase(): boolean { 46 | return inNotificationPhase; 47 | } 48 | 49 | export interface Reactive { 50 | [SIGNAL]: ReactiveNode; 51 | } 52 | 53 | export function isReactive(value: unknown): value is Reactive { 54 | return (value as Partial)[SIGNAL] !== undefined; 55 | } 56 | 57 | export const REACTIVE_NODE: ReactiveNode = { 58 | version: 0 as Version, 59 | lastCleanEpoch: 0 as Version, 60 | dirty: false, 61 | producerNode: undefined, 62 | producerLastReadVersion: undefined, 63 | producerIndexOfThis: undefined, 64 | nextProducerIndex: 0, 65 | liveConsumerNode: undefined, 66 | liveConsumerIndexOfThis: undefined, 67 | consumerAllowSignalWrites: false, 68 | consumerIsAlwaysLive: false, 69 | producerMustRecompute: () => false, 70 | producerRecomputeValue: () => {}, 71 | consumerMarkedDirty: () => {}, 72 | consumerOnSignalRead: () => {}, 73 | }; 74 | 75 | /** 76 | * A producer and/or consumer which participates in the reactive graph. 77 | * 78 | * Producer `ReactiveNode`s which are accessed when a consumer `ReactiveNode` is the 79 | * `activeConsumer` are tracked as dependencies of that consumer. 80 | * 81 | * Certain consumers are also tracked as "live" consumers and create edges in the other direction, 82 | * from producer to consumer. These edges are used to propagate change notifications when a 83 | * producer's value is updated. 84 | * 85 | * A `ReactiveNode` may be both a producer and consumer. 86 | */ 87 | export interface ReactiveNode { 88 | /** 89 | * Version of the value that this node produces. 90 | * 91 | * This is incremented whenever a new value is produced by this node which is not equal to the 92 | * previous value (by whatever definition of equality is in use). 93 | */ 94 | version: Version; 95 | 96 | /** 97 | * Epoch at which this node is verified to be clean. 98 | * 99 | * This allows skipping of some polling operations in the case where no signals have been set 100 | * since this node was last read. 101 | */ 102 | lastCleanEpoch: Version; 103 | 104 | /** 105 | * Whether this node (in its consumer capacity) is dirty. 106 | * 107 | * Only live consumers become dirty, when receiving a change notification from a dependency 108 | * producer. 109 | */ 110 | dirty: boolean; 111 | 112 | /** 113 | * Producers which are dependencies of this consumer. 114 | * 115 | * Uses the same indices as the `producerLastReadVersion` and `producerIndexOfThis` arrays. 116 | */ 117 | producerNode: ReactiveNode[] | undefined; 118 | 119 | /** 120 | * `Version` of the value last read by a given producer. 121 | * 122 | * Uses the same indices as the `producerNode` and `producerIndexOfThis` arrays. 123 | */ 124 | producerLastReadVersion: Version[] | undefined; 125 | 126 | /** 127 | * Index of `this` (consumer) in each producer's `liveConsumers` array. 128 | * 129 | * This value is only meaningful if this node is live (`liveConsumers.length > 0`). Otherwise 130 | * these indices are stale. 131 | * 132 | * Uses the same indices as the `producerNode` and `producerLastReadVersion` arrays. 133 | */ 134 | producerIndexOfThis: number[] | undefined; 135 | 136 | /** 137 | * Index into the producer arrays that the next dependency of this node as a consumer will use. 138 | * 139 | * This index is zeroed before this node as a consumer begins executing. When a producer is read, 140 | * it gets inserted into the producers arrays at this index. There may be an existing dependency 141 | * in this location which may or may not match the incoming producer, depending on whether the 142 | * same producers were read in the same order as the last computation. 143 | */ 144 | nextProducerIndex: number; 145 | 146 | /** 147 | * Array of consumers of this producer that are "live" (they require push notifications). 148 | * 149 | * `liveConsumerNode.length` is effectively our reference count for this node. 150 | */ 151 | liveConsumerNode: ReactiveNode[] | undefined; 152 | 153 | /** 154 | * Index of `this` (producer) in each consumer's `producerNode` array. 155 | * 156 | * Uses the same indices as the `liveConsumerNode` array. 157 | */ 158 | liveConsumerIndexOfThis: number[] | undefined; 159 | 160 | /** 161 | * Whether writes to signals are allowed when this consumer is the `activeConsumer`. 162 | * 163 | * This is used to enforce guardrails such as preventing writes to writable signals in the 164 | * computation function of computed signals, which is supposed to be pure. 165 | */ 166 | consumerAllowSignalWrites: boolean; 167 | 168 | readonly consumerIsAlwaysLive: boolean; 169 | 170 | /** 171 | * Tracks whether producers need to recompute their value independently of the reactive graph (for 172 | * example, if no initial value has been computed). 173 | */ 174 | producerMustRecompute(node: unknown): boolean; 175 | producerRecomputeValue(node: unknown): void; 176 | consumerMarkedDirty(this: unknown): void; 177 | 178 | /** 179 | * Called when a signal is read within this consumer. 180 | */ 181 | consumerOnSignalRead(node: unknown): void; 182 | 183 | /** 184 | * Called when the signal becomes "live" 185 | */ 186 | watched?(): void; 187 | 188 | /** 189 | * Called when the signal stops being "live" 190 | */ 191 | unwatched?(): void; 192 | 193 | /** 194 | * Optional extra data for embedder of this signal library. 195 | * Sent to various callbacks as the this value. 196 | */ 197 | wrapper?: any; 198 | } 199 | 200 | interface ConsumerNode extends ReactiveNode { 201 | producerNode: NonNullable; 202 | producerIndexOfThis: NonNullable; 203 | producerLastReadVersion: NonNullable; 204 | } 205 | 206 | interface ProducerNode extends ReactiveNode { 207 | liveConsumerNode: NonNullable; 208 | liveConsumerIndexOfThis: NonNullable; 209 | } 210 | 211 | /** 212 | * Called by implementations when a producer's signal is read. 213 | */ 214 | export function producerAccessed(node: ReactiveNode): void { 215 | if (inNotificationPhase) { 216 | throw new Error( 217 | typeof ngDevMode !== 'undefined' && ngDevMode 218 | ? `Assertion error: signal read during notification phase` 219 | : '', 220 | ); 221 | } 222 | 223 | if (activeConsumer === null) { 224 | // Accessed outside of a reactive context, so nothing to record. 225 | return; 226 | } 227 | 228 | activeConsumer.consumerOnSignalRead(node); 229 | 230 | // This producer is the `idx`th dependency of `activeConsumer`. 231 | const idx = activeConsumer.nextProducerIndex++; 232 | 233 | assertConsumerNode(activeConsumer); 234 | 235 | if (idx < activeConsumer.producerNode.length && activeConsumer.producerNode[idx] !== node) { 236 | // There's been a change in producers since the last execution of `activeConsumer`. 237 | // `activeConsumer.producerNode[idx]` holds a stale dependency which will be be removed and 238 | // replaced with `this`. 239 | // 240 | // If `activeConsumer` isn't live, then this is a no-op, since we can replace the producer in 241 | // `activeConsumer.producerNode` directly. However, if `activeConsumer` is live, then we need 242 | // to remove it from the stale producer's `liveConsumer`s. 243 | if (consumerIsLive(activeConsumer)) { 244 | const staleProducer = activeConsumer.producerNode[idx]; 245 | producerRemoveLiveConsumerAtIndex(staleProducer, activeConsumer.producerIndexOfThis[idx]); 246 | 247 | // At this point, the only record of `staleProducer` is the reference at 248 | // `activeConsumer.producerNode[idx]` which will be overwritten below. 249 | } 250 | } 251 | 252 | if (activeConsumer.producerNode[idx] !== node) { 253 | // We're a new dependency of the consumer (at `idx`). 254 | activeConsumer.producerNode[idx] = node; 255 | 256 | // If the active consumer is live, then add it as a live consumer. If not, then use 0 as a 257 | // placeholder value. 258 | activeConsumer.producerIndexOfThis[idx] = consumerIsLive(activeConsumer) 259 | ? producerAddLiveConsumer(node, activeConsumer, idx) 260 | : 0; 261 | } 262 | activeConsumer.producerLastReadVersion[idx] = node.version; 263 | } 264 | 265 | /** 266 | * Increment the global epoch counter. 267 | * 268 | * Called by source producers (that is, not computeds) whenever their values change. 269 | */ 270 | export function producerIncrementEpoch(): void { 271 | epoch++; 272 | } 273 | 274 | /** 275 | * Ensure this producer's `version` is up-to-date. 276 | */ 277 | export function producerUpdateValueVersion(node: ReactiveNode): void { 278 | if (!node.dirty && node.lastCleanEpoch === epoch) { 279 | // Even non-live consumers can skip polling if they previously found themselves to be clean at 280 | // the current epoch, since their dependencies could not possibly have changed (such a change 281 | // would've increased the epoch). 282 | return; 283 | } 284 | 285 | if (!node.producerMustRecompute(node) && !consumerPollProducersForChange(node)) { 286 | // None of our producers report a change since the last time they were read, so no 287 | // recomputation of our value is necessary, and we can consider ourselves clean. 288 | node.dirty = false; 289 | node.lastCleanEpoch = epoch; 290 | return; 291 | } 292 | 293 | node.producerRecomputeValue(node); 294 | 295 | // After recomputing the value, we're no longer dirty. 296 | node.dirty = false; 297 | node.lastCleanEpoch = epoch; 298 | } 299 | 300 | /** 301 | * Propagate a dirty notification to live consumers of this producer. 302 | */ 303 | export function producerNotifyConsumers(node: ReactiveNode): void { 304 | if (node.liveConsumerNode === undefined) { 305 | return; 306 | } 307 | 308 | // Prevent signal reads when we're updating the graph 309 | const prev = inNotificationPhase; 310 | inNotificationPhase = true; 311 | try { 312 | for (const consumer of node.liveConsumerNode) { 313 | if (!consumer.dirty) { 314 | consumerMarkDirty(consumer); 315 | } 316 | } 317 | } finally { 318 | inNotificationPhase = prev; 319 | } 320 | } 321 | 322 | /** 323 | * Whether this `ReactiveNode` in its producer capacity is currently allowed to initiate updates, 324 | * based on the current consumer context. 325 | */ 326 | export function producerUpdatesAllowed(): boolean { 327 | return activeConsumer?.consumerAllowSignalWrites !== false; 328 | } 329 | 330 | export function consumerMarkDirty(node: ReactiveNode): void { 331 | node.dirty = true; 332 | producerNotifyConsumers(node); 333 | node.consumerMarkedDirty?.call(node.wrapper ?? node); 334 | } 335 | 336 | /** 337 | * Prepare this consumer to run a computation in its reactive context. 338 | * 339 | * Must be called by subclasses which represent reactive computations, before those computations 340 | * begin. 341 | */ 342 | export function consumerBeforeComputation(node: ReactiveNode | null): ReactiveNode | null { 343 | node && (node.nextProducerIndex = 0); 344 | return setActiveConsumer(node); 345 | } 346 | 347 | /** 348 | * Finalize this consumer's state after a reactive computation has run. 349 | * 350 | * Must be called by subclasses which represent reactive computations, after those computations 351 | * have finished. 352 | */ 353 | export function consumerAfterComputation( 354 | node: ReactiveNode | null, 355 | prevConsumer: ReactiveNode | null, 356 | ): void { 357 | setActiveConsumer(prevConsumer); 358 | 359 | if ( 360 | !node || 361 | node.producerNode === undefined || 362 | node.producerIndexOfThis === undefined || 363 | node.producerLastReadVersion === undefined 364 | ) { 365 | return; 366 | } 367 | 368 | if (consumerIsLive(node)) { 369 | // For live consumers, we need to remove the producer -> consumer edge for any stale producers 370 | // which weren't dependencies after the recomputation. 371 | for (let i = node.nextProducerIndex; i < node.producerNode.length; i++) { 372 | producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); 373 | } 374 | } 375 | 376 | // Truncate the producer tracking arrays. 377 | // Perf note: this is essentially truncating the length to `node.nextProducerIndex`, but 378 | // benchmarking has shown that individual pop operations are faster. 379 | while (node.producerNode.length > node.nextProducerIndex) { 380 | node.producerNode.pop(); 381 | node.producerLastReadVersion.pop(); 382 | node.producerIndexOfThis.pop(); 383 | } 384 | } 385 | 386 | /** 387 | * Determine whether this consumer has any dependencies which have changed since the last time 388 | * they were read. 389 | */ 390 | export function consumerPollProducersForChange(node: ReactiveNode): boolean { 391 | assertConsumerNode(node); 392 | 393 | // Poll producers for change. 394 | for (let i = 0; i < node.producerNode.length; i++) { 395 | const producer = node.producerNode[i]; 396 | const seenVersion = node.producerLastReadVersion[i]; 397 | 398 | // First check the versions. A mismatch means that the producer's value is known to have 399 | // changed since the last time we read it. 400 | if (seenVersion !== producer.version) { 401 | return true; 402 | } 403 | 404 | // The producer's version is the same as the last time we read it, but it might itself be 405 | // stale. Force the producer to recompute its version (calculating a new value if necessary). 406 | producerUpdateValueVersion(producer); 407 | 408 | // Now when we do this check, `producer.version` is guaranteed to be up to date, so if the 409 | // versions still match then it has not changed since the last time we read it. 410 | if (seenVersion !== producer.version) { 411 | return true; 412 | } 413 | } 414 | 415 | return false; 416 | } 417 | 418 | /** 419 | * Disconnect this consumer from the graph. 420 | */ 421 | export function consumerDestroy(node: ReactiveNode): void { 422 | assertConsumerNode(node); 423 | if (consumerIsLive(node)) { 424 | // Drop all connections from the graph to this node. 425 | for (let i = 0; i < node.producerNode.length; i++) { 426 | producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); 427 | } 428 | } 429 | 430 | // Truncate all the arrays to drop all connection from this node to the graph. 431 | node.producerNode.length = 432 | node.producerLastReadVersion.length = 433 | node.producerIndexOfThis.length = 434 | 0; 435 | if (node.liveConsumerNode) { 436 | node.liveConsumerNode.length = node.liveConsumerIndexOfThis!.length = 0; 437 | } 438 | } 439 | 440 | /** 441 | * Add `consumer` as a live consumer of this node. 442 | * 443 | * Note that this operation is potentially transitive. If this node becomes live, then it becomes 444 | * a live consumer of all of its current producers. 445 | */ 446 | function producerAddLiveConsumer( 447 | node: ReactiveNode, 448 | consumer: ReactiveNode, 449 | indexOfThis: number, 450 | ): number { 451 | assertProducerNode(node); 452 | assertConsumerNode(node); 453 | if (node.liveConsumerNode.length === 0) { 454 | node.watched?.call(node.wrapper); 455 | // When going from 0 to 1 live consumers, we become a live consumer to our producers. 456 | for (let i = 0; i < node.producerNode.length; i++) { 457 | node.producerIndexOfThis[i] = producerAddLiveConsumer(node.producerNode[i], node, i); 458 | } 459 | } 460 | node.liveConsumerIndexOfThis.push(indexOfThis); 461 | return node.liveConsumerNode.push(consumer) - 1; 462 | } 463 | 464 | /** 465 | * Remove the live consumer at `idx`. 466 | */ 467 | export function producerRemoveLiveConsumerAtIndex(node: ReactiveNode, idx: number): void { 468 | assertProducerNode(node); 469 | assertConsumerNode(node); 470 | 471 | if (typeof ngDevMode !== 'undefined' && ngDevMode && idx >= node.liveConsumerNode.length) { 472 | throw new Error( 473 | `Assertion error: active consumer index ${idx} is out of bounds of ${node.liveConsumerNode.length} consumers)`, 474 | ); 475 | } 476 | 477 | if (node.liveConsumerNode.length === 1) { 478 | // When removing the last live consumer, we will no longer be live. We need to remove 479 | // ourselves from our producers' tracking (which may cause consumer-producers to lose 480 | // liveness as well). 481 | node.unwatched?.call(node.wrapper); 482 | for (let i = 0; i < node.producerNode.length; i++) { 483 | producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); 484 | } 485 | } 486 | 487 | // Move the last value of `liveConsumers` into `idx`. Note that if there's only a single 488 | // live consumer, this is a no-op. 489 | const lastIdx = node.liveConsumerNode.length - 1; 490 | node.liveConsumerNode[idx] = node.liveConsumerNode[lastIdx]; 491 | node.liveConsumerIndexOfThis[idx] = node.liveConsumerIndexOfThis[lastIdx]; 492 | 493 | // Truncate the array. 494 | node.liveConsumerNode.length--; 495 | node.liveConsumerIndexOfThis.length--; 496 | 497 | // If the index is still valid, then we need to fix the index pointer from the producer to this 498 | // consumer, and update it from `lastIdx` to `idx` (accounting for the move above). 499 | if (idx < node.liveConsumerNode.length) { 500 | const idxProducer = node.liveConsumerIndexOfThis[idx]; 501 | const consumer = node.liveConsumerNode[idx]; 502 | assertConsumerNode(consumer); 503 | consumer.producerIndexOfThis[idxProducer] = idx; 504 | } 505 | } 506 | 507 | function consumerIsLive(node: ReactiveNode): boolean { 508 | return node.consumerIsAlwaysLive || (node?.liveConsumerNode?.length ?? 0) > 0; 509 | } 510 | 511 | export function assertConsumerNode(node: ReactiveNode): asserts node is ConsumerNode { 512 | node.producerNode ??= []; 513 | node.producerIndexOfThis ??= []; 514 | node.producerLastReadVersion ??= []; 515 | } 516 | 517 | export function assertProducerNode(node: ReactiveNode): asserts node is ProducerNode { 518 | node.liveConsumerNode ??= []; 519 | node.liveConsumerIndexOfThis ??= []; 520 | } 521 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {Signal} from './wrapper.js'; 2 | -------------------------------------------------------------------------------- /src/public-api-types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The purpose of these tests is to make sure the types are exposed how we expect, 3 | * and that we double-check what we're exposing as public API 4 | */ 5 | import {expectTypeOf} from 'expect-type'; 6 | import {Signal} from './wrapper.ts'; 7 | 8 | /** 9 | * Top-Level 10 | */ 11 | expectTypeOf().toEqualTypeOf< 12 | 'State' | 'Computed' | 'subtle' | 'isState' | 'isComputed' | 'isWatcher' 13 | >(); 14 | 15 | /** 16 | * Construction works as expected 17 | */ 18 | expectTypeOf(Signal.State).toBeConstructibleWith(1); 19 | expectTypeOf(Signal.State).toBeConstructibleWith(1, {}); 20 | expectTypeOf(Signal.State).toBeConstructibleWith(1, {equals: (a, b) => true}); 21 | expectTypeOf(Signal.State).toBeConstructibleWith(1, {[Signal.subtle.watched]: () => true}); 22 | expectTypeOf(Signal.State).toBeConstructibleWith(1, { 23 | [Signal.subtle.unwatched]: () => true, 24 | }); 25 | expectTypeOf(Signal.Computed).toBeConstructibleWith(() => 2); 26 | expectTypeOf(Signal.Computed).toBeConstructibleWith(() => 1, {equals: (a, b) => true}); 27 | expectTypeOf(Signal.Computed).toBeConstructibleWith(() => 1, { 28 | [Signal.subtle.watched]: () => true, 29 | }); 30 | expectTypeOf(Signal.Computed).toBeConstructibleWith(() => 1, { 31 | [Signal.subtle.unwatched]: () => true, 32 | }); 33 | 34 | // @ts-expect-error 35 | expectTypeOf>().toBeConstructibleWith(); 36 | // @ts-expect-error 37 | expectTypeOf(Signal.State).toBeConstructibleWith('wrong', {}); 38 | expectTypeOf(Signal.State).toBeConstructibleWith(1, { 39 | // @ts-expect-error 40 | [Signal.subtle.watched]: 2, 41 | }); 42 | expectTypeOf(Signal.State).toBeConstructibleWith(1, { 43 | // @ts-expect-error 44 | [Signal.subtle.unwatched]: 2, 45 | }); 46 | expectTypeOf(Signal.State).toBeConstructibleWith(1, { 47 | // @ts-expect-error 48 | typo: (a, b) => true, 49 | }); 50 | // @ts-expect-error 51 | expectTypeOf>().toBeConstructibleWith(); 52 | // @ts-expect-error 53 | expectTypeOf(Signal.Computed).toBeConstructibleWith('wrong'); 54 | // @ts-expect-error 55 | expectTypeOf(Signal.Computed).toBeConstructibleWith(2); 56 | expectTypeOf(Signal.Computed).toBeConstructibleWith(() => 1, { 57 | // @ts-expect-error 58 | [Signal.subtle.watched]: 2, 59 | }); 60 | expectTypeOf(Signal.Computed).toBeConstructibleWith(() => 1, { 61 | // @ts-expect-error 62 | [Signal.subtle.unwatched]: 2, 63 | }); 64 | expectTypeOf(Signal.Computed).toBeConstructibleWith(() => 1, { 65 | // @ts-expect-error 66 | typo: (a, b) => true, 67 | }); 68 | 69 | /** 70 | * Properties on each of the instances / namespaces 71 | */ 72 | expectTypeOf & string>().toEqualTypeOf<'get' | 'set'>(); 73 | expectTypeOf & string>().toEqualTypeOf<'get'>(); 74 | expectTypeOf().toEqualTypeOf< 75 | | 'untrack' 76 | | 'currentComputed' 77 | | 'introspectSources' 78 | | 'introspectSinks' 79 | | 'hasSinks' 80 | | 'hasSources' 81 | | 'Watcher' 82 | | 'watched' 83 | | 'unwatched' 84 | >(); 85 | 86 | expectTypeOf().toEqualTypeOf< 87 | 'watch' | 'unwatch' | 'getPending' 88 | >(); 89 | 90 | /** 91 | * Inference works 92 | */ 93 | expectTypeOf(new Signal.State(0)).toEqualTypeOf>(); 94 | expectTypeOf(new Signal.State(0).get()).toEqualTypeOf(); 95 | expectTypeOf(new Signal.State(0).set(1)).toEqualTypeOf(); 96 | 97 | /** 98 | * Assigning subtypes works 99 | */ 100 | expectTypeOf>().toMatchTypeOf>(); 101 | expectTypeOf>().toMatchTypeOf>(); 102 | 103 | /** 104 | * Test data types 105 | */ 106 | interface Broader { 107 | strProp: string; 108 | } 109 | interface Narrower extends Broader { 110 | numProp: number; 111 | } 112 | -------------------------------------------------------------------------------- /src/signal.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | import {defaultEquals, ValueEqualityComparer} from './equality.js'; 10 | import {throwInvalidWriteToSignalError} from './errors.js'; 11 | import { 12 | producerAccessed, 13 | producerIncrementEpoch, 14 | producerNotifyConsumers, 15 | producerUpdatesAllowed, 16 | REACTIVE_NODE, 17 | ReactiveNode, 18 | SIGNAL, 19 | } from './graph.js'; 20 | 21 | // Required as the signals library is in a separate package, so we need to explicitly ensure the 22 | // global `ngDevMode` type is defined. 23 | declare const ngDevMode: boolean | undefined; 24 | 25 | /** 26 | * If set, called after `WritableSignal`s are updated. 27 | * 28 | * This hook can be used to achieve various effects, such as running effects synchronously as part 29 | * of setting a signal. 30 | */ 31 | let postSignalSetFn: (() => void) | null = null; 32 | 33 | export interface SignalNode extends ReactiveNode, ValueEqualityComparer { 34 | value: T; 35 | } 36 | 37 | export type SignalBaseGetter = (() => T) & {readonly [SIGNAL]: unknown}; 38 | 39 | // Note: Closure *requires* this to be an `interface` and not a type, which is why the 40 | // `SignalBaseGetter` type exists to provide the correct shape. 41 | export interface SignalGetter extends SignalBaseGetter { 42 | readonly [SIGNAL]: SignalNode; 43 | } 44 | 45 | /** 46 | * Create a `Signal` that can be set or updated directly. 47 | */ 48 | export function createSignal(initialValue: T): SignalGetter { 49 | const node: SignalNode = Object.create(SIGNAL_NODE); 50 | node.value = initialValue; 51 | const getter = (() => { 52 | producerAccessed(node); 53 | return node.value; 54 | }) as SignalGetter; 55 | (getter as any)[SIGNAL] = node; 56 | return getter; 57 | } 58 | 59 | export function setPostSignalSetFn(fn: (() => void) | null): (() => void) | null { 60 | const prev = postSignalSetFn; 61 | postSignalSetFn = fn; 62 | return prev; 63 | } 64 | 65 | export function signalGetFn(this: SignalNode): T { 66 | producerAccessed(this); 67 | return this.value; 68 | } 69 | 70 | export function signalSetFn(node: SignalNode, newValue: T) { 71 | if (!producerUpdatesAllowed()) { 72 | throwInvalidWriteToSignalError(); 73 | } 74 | 75 | if (!node.equal.call(node.wrapper, node.value, newValue)) { 76 | node.value = newValue; 77 | signalValueChanged(node); 78 | } 79 | } 80 | 81 | export function signalUpdateFn(node: SignalNode, updater: (value: T) => T): void { 82 | if (!producerUpdatesAllowed()) { 83 | throwInvalidWriteToSignalError(); 84 | } 85 | 86 | signalSetFn(node, updater(node.value)); 87 | } 88 | 89 | // Note: Using an IIFE here to ensure that the spread assignment is not considered 90 | // a side-effect, ending up preserving `COMPUTED_NODE` and `REACTIVE_NODE`. 91 | // TODO: remove when https://github.com/evanw/esbuild/issues/3392 is resolved. 92 | export const SIGNAL_NODE: SignalNode = /* @__PURE__ */ (() => { 93 | return { 94 | ...REACTIVE_NODE, 95 | equal: defaultEquals, 96 | value: undefined, 97 | }; 98 | })(); 99 | 100 | function signalValueChanged(node: SignalNode): void { 101 | node.version++; 102 | producerIncrementEpoch(); 103 | producerNotifyConsumers(node); 104 | postSignalSetFn?.(); 105 | } 106 | -------------------------------------------------------------------------------- /src/wrapper.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2024 Bloomberg Finance L.P. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import {computedGet, createComputed, type ComputedNode} from './computed.js'; 19 | import { 20 | SIGNAL, 21 | getActiveConsumer, 22 | isInNotificationPhase, 23 | producerAccessed, 24 | assertConsumerNode, 25 | setActiveConsumer, 26 | REACTIVE_NODE, 27 | type ReactiveNode, 28 | assertProducerNode, 29 | producerRemoveLiveConsumerAtIndex, 30 | } from './graph.js'; 31 | import {createSignal, signalGetFn, signalSetFn, type SignalNode} from './signal.js'; 32 | 33 | const NODE: unique symbol = Symbol('node'); 34 | 35 | // eslint-disable-next-line @typescript-eslint/no-namespace 36 | export namespace Signal { 37 | export let isState: (s: any) => boolean, 38 | isComputed: (s: any) => boolean, 39 | isWatcher: (s: any) => boolean; 40 | 41 | // A read-write Signal 42 | export class State { 43 | readonly [NODE]: SignalNode; 44 | #brand() {} 45 | 46 | static { 47 | isState = (s) => typeof s === 'object' && #brand in s; 48 | } 49 | 50 | constructor(initialValue: T, options: Signal.Options = {}) { 51 | const ref = createSignal(initialValue); 52 | const node: SignalNode = ref[SIGNAL]; 53 | this[NODE] = node; 54 | node.wrapper = this; 55 | if (options) { 56 | const equals = options.equals; 57 | if (equals) { 58 | node.equal = equals; 59 | } 60 | node.watched = options[Signal.subtle.watched]; 61 | node.unwatched = options[Signal.subtle.unwatched]; 62 | } 63 | } 64 | 65 | public get(): T { 66 | if (!isState(this)) throw new TypeError('Wrong receiver type for Signal.State.prototype.get'); 67 | return (signalGetFn).call(this[NODE]); 68 | } 69 | 70 | public set(newValue: T): void { 71 | if (!isState(this)) throw new TypeError('Wrong receiver type for Signal.State.prototype.set'); 72 | if (isInNotificationPhase()) { 73 | throw new Error('Writes to signals not permitted during Watcher callback'); 74 | } 75 | const ref = this[NODE]; 76 | signalSetFn(ref, newValue); 77 | } 78 | } 79 | 80 | // A Signal which is a formula based on other Signals 81 | export class Computed { 82 | readonly [NODE]: ComputedNode; 83 | 84 | #brand() {} 85 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 86 | static { 87 | isComputed = (c: any) => typeof c === 'object' && #brand in c; 88 | } 89 | 90 | // Create a Signal which evaluates to the value returned by the callback. 91 | // Callback is called with this signal as the parameter. 92 | constructor(computation: () => T, options?: Signal.Options) { 93 | const ref = createComputed(computation); 94 | const node = ref[SIGNAL]; 95 | node.consumerAllowSignalWrites = true; 96 | this[NODE] = node; 97 | node.wrapper = this; 98 | if (options) { 99 | const equals = options.equals; 100 | if (equals) { 101 | node.equal = equals; 102 | } 103 | node.watched = options[Signal.subtle.watched]; 104 | node.unwatched = options[Signal.subtle.unwatched]; 105 | } 106 | } 107 | 108 | get(): T { 109 | if (!isComputed(this)) 110 | throw new TypeError('Wrong receiver type for Signal.Computed.prototype.get'); 111 | return computedGet(this[NODE]); 112 | } 113 | } 114 | 115 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 116 | type AnySignal = State | Computed; 117 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 118 | type AnySink = Computed | subtle.Watcher; 119 | 120 | // eslint-disable-next-line @typescript-eslint/no-namespace 121 | export namespace subtle { 122 | // Run a callback with all tracking disabled (even for nested computed). 123 | export function untrack(cb: () => T): T { 124 | let output: T; 125 | let prevActiveConsumer = null; 126 | try { 127 | prevActiveConsumer = setActiveConsumer(null); 128 | output = cb(); 129 | } finally { 130 | setActiveConsumer(prevActiveConsumer); 131 | } 132 | return output; 133 | } 134 | 135 | // Returns ordered list of all signals which this one referenced 136 | // during the last time it was evaluated 137 | export function introspectSources(sink: AnySink): AnySignal[] { 138 | if (!isComputed(sink) && !isWatcher(sink)) { 139 | throw new TypeError('Called introspectSources without a Computed or Watcher argument'); 140 | } 141 | return sink[NODE].producerNode?.map((n) => n.wrapper) ?? []; 142 | } 143 | 144 | // Returns the subset of signal sinks which recursively 145 | // lead to an Effect which has not been disposed 146 | // Note: Only watched Computed signals will be in this list. 147 | export function introspectSinks(signal: AnySignal): AnySink[] { 148 | if (!isComputed(signal) && !isState(signal)) { 149 | throw new TypeError('Called introspectSinks without a Signal argument'); 150 | } 151 | return signal[NODE].liveConsumerNode?.map((n) => n.wrapper) ?? []; 152 | } 153 | 154 | // True iff introspectSinks() is non-empty 155 | export function hasSinks(signal: AnySignal): boolean { 156 | if (!isComputed(signal) && !isState(signal)) { 157 | throw new TypeError('Called hasSinks without a Signal argument'); 158 | } 159 | const liveConsumerNode = signal[NODE].liveConsumerNode; 160 | if (!liveConsumerNode) return false; 161 | return liveConsumerNode.length > 0; 162 | } 163 | 164 | // True iff introspectSources() is non-empty 165 | export function hasSources(signal: AnySink): boolean { 166 | if (!isComputed(signal) && !isWatcher(signal)) { 167 | throw new TypeError('Called hasSources without a Computed or Watcher argument'); 168 | } 169 | const producerNode = signal[NODE].producerNode; 170 | if (!producerNode) return false; 171 | return producerNode.length > 0; 172 | } 173 | 174 | export class Watcher { 175 | readonly [NODE]: ReactiveNode; 176 | 177 | #brand() {} 178 | static { 179 | isWatcher = (w: any): w is Watcher => #brand in w; 180 | } 181 | 182 | // When a (recursive) source of Watcher is written to, call this callback, 183 | // if it hasn't already been called since the last `watch` call. 184 | // No signals may be read or written during the notify. 185 | constructor(notify: (this: Watcher) => void) { 186 | let node = Object.create(REACTIVE_NODE); 187 | node.wrapper = this; 188 | node.consumerMarkedDirty = notify; 189 | node.consumerIsAlwaysLive = true; 190 | node.consumerAllowSignalWrites = false; 191 | node.producerNode = []; 192 | this[NODE] = node; 193 | } 194 | 195 | #assertSignals(signals: AnySignal[]): void { 196 | for (const signal of signals) { 197 | if (!isComputed(signal) && !isState(signal)) { 198 | throw new TypeError('Called watch/unwatch without a Computed or State argument'); 199 | } 200 | } 201 | } 202 | 203 | // Add these signals to the Watcher's set, and set the watcher to run its 204 | // notify callback next time any signal in the set (or one of its dependencies) changes. 205 | // Can be called with no arguments just to reset the "notified" state, so that 206 | // the notify callback will be invoked again. 207 | watch(...signals: AnySignal[]): void { 208 | if (!isWatcher(this)) { 209 | throw new TypeError('Called unwatch without Watcher receiver'); 210 | } 211 | this.#assertSignals(signals); 212 | 213 | const node = this[NODE]; 214 | node.dirty = false; // Give the watcher a chance to trigger again 215 | const prev = setActiveConsumer(node); 216 | for (const signal of signals) { 217 | producerAccessed(signal[NODE]); 218 | } 219 | setActiveConsumer(prev); 220 | } 221 | 222 | // Remove these signals from the watched set (e.g., for an effect which is disposed) 223 | unwatch(...signals: AnySignal[]): void { 224 | if (!isWatcher(this)) { 225 | throw new TypeError('Called unwatch without Watcher receiver'); 226 | } 227 | this.#assertSignals(signals); 228 | 229 | const node = this[NODE]; 230 | assertConsumerNode(node); 231 | 232 | for (let i = node.producerNode.length - 1; i >= 0; i--) { 233 | if (signals.includes(node.producerNode[i].wrapper)) { 234 | producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); 235 | 236 | // Logic copied from producerRemoveLiveConsumerAtIndex, but reversed 237 | const lastIdx = node.producerNode!.length - 1; 238 | node.producerNode![i] = node.producerNode![lastIdx]; 239 | node.producerIndexOfThis[i] = node.producerIndexOfThis[lastIdx]; 240 | 241 | node.producerNode.length--; 242 | node.producerIndexOfThis.length--; 243 | node.nextProducerIndex--; 244 | 245 | if (i < node.producerNode.length) { 246 | const idxConsumer = node.producerIndexOfThis[i]; 247 | const producer = node.producerNode[i]; 248 | assertProducerNode(producer); 249 | producer.liveConsumerIndexOfThis[idxConsumer] = i; 250 | } 251 | } 252 | } 253 | } 254 | 255 | // Returns the set of computeds in the Watcher's set which are still yet 256 | // to be re-evaluated 257 | getPending(): Computed[] { 258 | if (!isWatcher(this)) { 259 | throw new TypeError('Called getPending without Watcher receiver'); 260 | } 261 | const node = this[NODE]; 262 | return node.producerNode!.filter((n) => n.dirty).map((n) => n.wrapper); 263 | } 264 | } 265 | 266 | export function currentComputed(): Computed | undefined { 267 | return getActiveConsumer()?.wrapper; 268 | } 269 | 270 | // Hooks to observe being watched or no longer watched 271 | export const watched = Symbol('watched'); 272 | export const unwatched = Symbol('unwatched'); 273 | } 274 | 275 | export interface Options { 276 | // Custom comparison function between old and new value. Default: Object.is. 277 | // The signal is passed in as an optionally-used third parameter for context. 278 | equals?: (this: AnySignal, t: T, t2: T) => boolean; 279 | 280 | // Callback called when hasSinks becomes true, if it was previously false 281 | [Signal.subtle.watched]?: (this: AnySignal) => void; 282 | 283 | // Callback called whenever hasSinks becomes false, if it was previously true 284 | [Signal.subtle.unwatched]?: (this: AnySignal) => void; 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /tests/Signal/computed.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from 'vitest'; 2 | import {Signal} from '../../src/wrapper.js'; 3 | 4 | describe('Computed', () => { 5 | it('should work', () => { 6 | const stateSignal = new Signal.State(1); 7 | 8 | const computedSignal = new Signal.Computed(() => { 9 | const f = stateSignal.get() * 2; 10 | return f; 11 | }); 12 | 13 | expect(computedSignal.get()).toEqual(2); 14 | 15 | stateSignal.set(5); 16 | 17 | expect(stateSignal.get()).toEqual(5); 18 | expect(computedSignal.get()).toEqual(10); 19 | }); 20 | 21 | describe('Comparison semantics', () => { 22 | it('should track Computed by Object.is', () => { 23 | const state = new Signal.State(1); 24 | let value = 5; 25 | let calls = 0; 26 | const computed = new Signal.Computed(() => (state.get(), value)); 27 | const c2 = new Signal.Computed(() => (calls++, computed.get())); 28 | 29 | expect(calls).toBe(0); 30 | expect(c2.get()).toBe(5); 31 | expect(calls).toBe(1); 32 | state.set(2); 33 | expect(c2.get()).toBe(5); 34 | expect(calls).toBe(1); 35 | value = NaN; 36 | expect(c2.get()).toBe(5); 37 | expect(calls).toBe(1); 38 | state.set(3); 39 | expect(c2.get()).toBe(NaN); 40 | expect(calls).toBe(2); 41 | state.set(4); 42 | expect(c2.get()).toBe(NaN); 43 | expect(calls).toBe(2); 44 | }); 45 | 46 | it('applies custom equality in Computed', () => { 47 | const s = new Signal.State(5); 48 | let ecalls = 0; 49 | const c1 = new Signal.Computed(() => (s.get(), 1), { 50 | equals() { 51 | ecalls++; 52 | return false; 53 | }, 54 | }); 55 | let calls = 0; 56 | const c2 = new Signal.Computed(() => { 57 | calls++; 58 | return c1.get(); 59 | }); 60 | 61 | expect(calls).toBe(0); 62 | expect(ecalls).toBe(0); 63 | 64 | expect(c2.get()).toBe(1); 65 | expect(ecalls).toBe(0); 66 | expect(calls).toBe(1); 67 | 68 | s.set(10); 69 | expect(c2.get()).toBe(1); 70 | expect(ecalls).toBe(1); 71 | expect(calls).toBe(2); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /tests/Signal/ported/preact.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, it, expect, vi} from 'vitest'; 2 | import {Signal} from '../../../src'; 3 | 4 | describe('Ported - Preact', () => { 5 | // https://github.com/preactjs/signals/blob/main/packages/core/test/signal.test.tsx#L1078 6 | it('should not leak errors raised by dependencies', () => { 7 | const a = new Signal.State(0); 8 | const b = new Signal.Computed(() => { 9 | a.get(); 10 | throw new Error('error'); 11 | }); 12 | const c = new Signal.Computed(() => { 13 | try { 14 | b.get(); 15 | } catch { 16 | return 'ok'; 17 | } 18 | }); 19 | expect(c.get()).to.equal('ok'); 20 | a.set(1); 21 | expect(c.get()).to.equal('ok'); 22 | }); 23 | 24 | // https://github.com/preactjs/signals/blob/main/packages/core/test/signal.test.tsx#L914 25 | it('should return updated value', () => { 26 | const a = new Signal.State('a'); 27 | const b = new Signal.State('b'); 28 | 29 | const c = new Signal.Computed(() => a.get() + b.get()); 30 | expect(c.get()).to.equal('ab'); 31 | 32 | a.set('aa'); 33 | expect(c.get()).to.equal('aab'); 34 | }); 35 | 36 | // https://github.com/preactjs/signals/blob/main/packages/core/test/signal.test.tsx#L925 37 | it('should be lazily computed on demand', () => { 38 | const a = new Signal.State('a'); 39 | const b = new Signal.State('b'); 40 | const spy = vi.fn(() => a.get() + b.get()); 41 | const c = new Signal.Computed(spy); 42 | expect(spy).not.toHaveBeenCalled(); 43 | c.get(); 44 | expect(spy).toHaveBeenCalledOnce(); 45 | a.set('x'); 46 | b.set('y'); 47 | expect(spy).toHaveBeenCalledOnce(); 48 | c.get(); 49 | expect(spy).toHaveBeenCalledTimes(2); 50 | }); 51 | 52 | // https://github.com/preactjs/signals/blob/main/packages/core/test/signal.test.tsx#L940 53 | it('should be computed only when a dependency has changed at some point', () => { 54 | const a = new Signal.State('a'); 55 | const spy = vi.fn(() => { 56 | return a.get(); 57 | }); 58 | const c = new Signal.Computed(spy); 59 | c.get(); 60 | expect(spy).toHaveBeenCalledOnce(); 61 | a.set('a'); 62 | c.get(); 63 | expect(spy).toHaveBeenCalledOnce(); 64 | }); 65 | 66 | // https://github.com/preactjs/signals/blob/main/packages/core/test/signal.test.tsx#L1693 67 | it('should support lazy branches', () => { 68 | const a = new Signal.State(0); 69 | const b = new Signal.Computed(() => a.get()); 70 | const c = new Signal.Computed(() => (a.get() > 0 ? a.get() : b.get())); 71 | 72 | expect(c.get()).to.equal(0); 73 | a.set(1); 74 | expect(c.get()).to.equal(1); 75 | 76 | a.set(0); 77 | expect(c.get()).to.equal(0); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /tests/Signal/ported/vue.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, it, expect, vi} from 'vitest'; 2 | import {Signal} from '../../../src'; 3 | 4 | describe('Ported - Vue', () => { 5 | // https://github.com/vuejs/core/blob/main/packages/reactivity/__tests__/computed.spec.ts#L32 6 | it('should return updated value', () => { 7 | const s = new Signal.State<{foo?: number}>({}); 8 | const c = new Signal.Computed(() => s.get().foo); 9 | 10 | expect(c.get()).toBe(undefined); 11 | s.set({foo: 1}); 12 | expect(c.get()).toBe(1); 13 | }); 14 | 15 | // https://github.com/vuejs/core/blob/main/packages/reactivity/__tests__/computed.spec.ts#L54 16 | it('should compute lazily', () => { 17 | const s = new Signal.State<{foo?: number}>({}); 18 | const getter = vi.fn(() => s.get().foo); 19 | const c = new Signal.Computed(getter); 20 | 21 | // lazy 22 | expect(getter).not.toHaveBeenCalled(); 23 | 24 | expect(c.get()).toBe(undefined); 25 | expect(getter).toHaveBeenCalledTimes(1); 26 | 27 | // should not compute again 28 | c.get(); 29 | expect(getter).toHaveBeenCalledTimes(1); 30 | 31 | // should not compute until needed 32 | s.set({foo: 1}); 33 | expect(getter).toHaveBeenCalledTimes(1); 34 | 35 | // now it should compute 36 | expect(c.get()).toBe(1); 37 | expect(getter).toHaveBeenCalledTimes(2); 38 | 39 | // should not compute again 40 | c.get(); 41 | expect(getter).toHaveBeenCalledTimes(2); 42 | }); 43 | 44 | // https://github.com/vuejs/core/blob/main/packages/reactivity/__tests__/computed.spec.ts#L488 45 | it('should work when chained(ref+computed)', () => { 46 | const v = new Signal.State(0); 47 | const c1 = new Signal.Computed(() => { 48 | if (v.get() === 0) { 49 | v.set(1); 50 | } 51 | return 'foo'; 52 | }); 53 | const c2 = new Signal.Computed(() => v.get() + c1.get()); 54 | expect(c2.get()).toBe('0foo'); 55 | expect(c2.get()).toBe('0foo'); // ! In vue it recomputes and becomes '1foo' 56 | }); 57 | 58 | // https://github.com/vuejs/core/blob/main/packages/reactivity/__tests__/computed.spec.ts#L925 59 | it('should be recomputed without being affected by side effects', () => { 60 | const v = new Signal.State(0); 61 | const c1 = new Signal.Computed(() => { 62 | v.set(1); 63 | return 0; 64 | }); 65 | const c2 = new Signal.Computed(() => { 66 | return v.get() + ',' + c1.get(); 67 | }); 68 | 69 | expect(c2.get()).toBe('0,0'); 70 | v.set(1); 71 | expect(c2.get()).toBe('0,0'); // ! In vue it recomputes and becomes '1,0' 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /tests/Signal/state.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from 'vitest'; 2 | import {Signal} from '../../src/wrapper.js'; 3 | 4 | describe('Signal.State', () => { 5 | it('should work', () => { 6 | const stateSignal = new Signal.State(0); 7 | expect(stateSignal.get()).toEqual(0); 8 | 9 | stateSignal.set(10); 10 | 11 | expect(stateSignal.get()).toEqual(10); 12 | }); 13 | 14 | describe('Comparison semantics', () => { 15 | it('should cache State by Object.is', () => { 16 | const state = new Signal.State(NaN); 17 | let calls = 0; 18 | const computed = new Signal.Computed(() => { 19 | calls++; 20 | return state.get(); 21 | }); 22 | expect(calls).toBe(0); 23 | expect(computed.get()).toBe(NaN); 24 | expect(calls).toBe(1); 25 | state.set(NaN); 26 | expect(computed.get()).toBe(NaN); 27 | expect(calls).toBe(1); 28 | }); 29 | 30 | it('applies custom equality in State', () => { 31 | let ecalls = 0; 32 | const state = new Signal.State(1, { 33 | equals() { 34 | ecalls++; 35 | return false; 36 | }, 37 | }); 38 | let calls = 0; 39 | const computed = new Signal.Computed(() => { 40 | calls++; 41 | return state.get(); 42 | }); 43 | 44 | expect(calls).toBe(0); 45 | expect(ecalls).toBe(0); 46 | 47 | expect(computed.get()).toBe(1); 48 | expect(ecalls).toBe(0); 49 | expect(calls).toBe(1); 50 | 51 | state.set(1); 52 | expect(computed.get()).toBe(1); 53 | expect(ecalls).toBe(1); 54 | expect(calls).toBe(2); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /tests/Signal/subtle/currentComputed.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from 'vitest'; 2 | import {Signal} from '../../../src/wrapper.js'; 3 | 4 | describe('currentComputed', () => { 5 | it('works', () => { 6 | expect(Signal.subtle.currentComputed()).toBe(undefined); 7 | let context; 8 | let c = new Signal.Computed(() => (context = Signal.subtle.currentComputed())); 9 | c.get(); 10 | expect(c).toBe(context); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /tests/Signal/subtle/untrack.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from 'vitest'; 2 | import {Signal} from '../../../src/wrapper.js'; 3 | 4 | describe('Untrack', () => { 5 | it('works', () => { 6 | const state = new Signal.State(1); 7 | const computed = new Signal.Computed(() => Signal.subtle.untrack(() => state.get())); 8 | expect(computed.get()).toBe(1); 9 | state.set(2); 10 | expect(computed.get()).toBe(1); 11 | }); 12 | 13 | it('works differently without untrack', () => { 14 | const state = new Signal.State(1); 15 | const computed = new Signal.Computed(() => state.get()); 16 | expect(computed.get()).toBe(1); 17 | state.set(2); 18 | expect(computed.get()).toBe(2); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /tests/Signal/subtle/watch-unwatch.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it, vi} from 'vitest'; 2 | import {Signal} from '../../../src/wrapper.js'; 3 | 4 | describe('watch and unwatch', () => { 5 | it('handles multiple watchers well', () => { 6 | const s = new Signal.State(1); 7 | const s2 = new Signal.State(2); 8 | let n = 0; 9 | const w = new Signal.subtle.Watcher(() => n++); 10 | w.watch(s, s2); 11 | 12 | s.set(4); 13 | expect(n).toBe(1); 14 | expect(w.getPending()).toStrictEqual([]); 15 | 16 | w.watch(); 17 | s2.set(8); 18 | expect(n).toBe(2); 19 | 20 | w.unwatch(s); 21 | s.set(3); 22 | expect(n).toBe(2); 23 | 24 | w.watch(); 25 | s2.set(3); 26 | expect(n).toBe(3); 27 | 28 | w.watch(); 29 | s.set(2); 30 | expect(n).toBe(3); 31 | }); 32 | it('understands dynamic dependency sets', () => { 33 | let w1 = 0, 34 | u1 = 0, 35 | w2 = 0, 36 | u2 = 0, 37 | n = 0, 38 | d = 0; 39 | let s1 = new Signal.State(1, { 40 | [Signal.subtle.watched]() { 41 | w1++; 42 | }, 43 | [Signal.subtle.unwatched]() { 44 | u1++; 45 | }, 46 | }); 47 | let s2 = new Signal.State(2, { 48 | [Signal.subtle.watched]() { 49 | w2++; 50 | }, 51 | [Signal.subtle.unwatched]() { 52 | u2++; 53 | }, 54 | }); 55 | let which: {get(): number} = s1; 56 | let c = new Signal.Computed(() => (d++, which.get())); 57 | let w = new Signal.subtle.Watcher(() => n++); 58 | 59 | w.watch(c); 60 | expect(w1 + w2 + u1 + u2 + n + d).toBe(0); 61 | expect(Signal.subtle.hasSinks(s1)).toBe(false); 62 | expect(Signal.subtle.hasSinks(s2)).toBe(false); 63 | expect(w.getPending()).toStrictEqual([c]); 64 | 65 | expect(c.get()).toBe(1); 66 | expect(w1).toBe(1); 67 | expect(u1).toBe(0); 68 | expect(w2).toBe(0); 69 | expect(u2).toBe(0); 70 | expect(n).toBe(0); 71 | expect(Signal.subtle.hasSinks(s1)).toBe(true); 72 | expect(Signal.subtle.hasSinks(s2)).toBe(false); 73 | expect(w.getPending()).toStrictEqual([]); 74 | expect(d).toBe(1); 75 | 76 | s1.set(3); 77 | expect(w1).toBe(1); 78 | expect(u1).toBe(0); 79 | expect(w2).toBe(0); 80 | expect(u2).toBe(0); 81 | expect(n).toBe(1); 82 | expect(Signal.subtle.hasSinks(s1)).toBe(true); 83 | expect(Signal.subtle.hasSinks(s2)).toBe(false); 84 | expect(w.getPending()).toStrictEqual([c]); 85 | expect(d).toBe(1); 86 | 87 | expect(c.get()).toBe(3); 88 | expect(w1).toBe(1); 89 | expect(u1).toBe(0); 90 | expect(w2).toBe(0); 91 | expect(u2).toBe(0); 92 | expect(n).toBe(1); 93 | expect(Signal.subtle.hasSinks(s1)).toBe(true); 94 | expect(Signal.subtle.hasSinks(s2)).toBe(false); 95 | expect(w.getPending()).toStrictEqual([]); 96 | expect(d).toBe(2); 97 | 98 | which = s2; 99 | w.watch(); 100 | s1.set(4); 101 | expect(w1).toBe(1); 102 | expect(u1).toBe(0); 103 | expect(w2).toBe(0); 104 | expect(u2).toBe(0); 105 | expect(n).toBe(2); 106 | expect(Signal.subtle.hasSinks(s1)).toBe(true); 107 | expect(Signal.subtle.hasSinks(s2)).toBe(false); 108 | expect(w.getPending()).toStrictEqual([c]); 109 | expect(d).toBe(2); 110 | 111 | expect(c.get()).toBe(2); 112 | expect(w1).toBe(1); 113 | expect(u1).toBe(1); 114 | expect(w2).toBe(1); 115 | expect(u2).toBe(0); 116 | expect(n).toBe(2); 117 | expect(Signal.subtle.hasSinks(s1)).toBe(false); 118 | expect(Signal.subtle.hasSinks(s2)).toBe(true); 119 | expect(w.getPending()).toStrictEqual([]); 120 | expect(d).toBe(3); 121 | 122 | w.watch(); 123 | which = { 124 | get() { 125 | return 10; 126 | }, 127 | }; 128 | s1.set(5); 129 | expect(c.get()).toBe(2); 130 | expect(w1).toBe(1); 131 | expect(u1).toBe(1); 132 | expect(w2).toBe(1); 133 | expect(u2).toBe(0); 134 | expect(n).toBe(2); 135 | expect(Signal.subtle.hasSinks(s1)).toBe(false); 136 | expect(Signal.subtle.hasSinks(s2)).toBe(true); 137 | expect(w.getPending()).toStrictEqual([]); 138 | expect(d).toBe(3); 139 | 140 | w.watch(); 141 | s2.set(0); 142 | expect(w1).toBe(1); 143 | expect(u1).toBe(1); 144 | expect(w2).toBe(1); 145 | expect(u2).toBe(0); 146 | expect(n).toBe(3); 147 | expect(Signal.subtle.hasSinks(s1)).toBe(false); 148 | expect(Signal.subtle.hasSinks(s2)).toBe(true); 149 | expect(w.getPending()).toStrictEqual([c]); 150 | expect(d).toBe(3); 151 | 152 | expect(c.get()).toBe(10); 153 | expect(w1).toBe(1); 154 | expect(u1).toBe(1); 155 | expect(w2).toBe(1); 156 | expect(u2).toBe(1); 157 | expect(n).toBe(3); 158 | expect(Signal.subtle.hasSinks(s1)).toBe(false); 159 | expect(Signal.subtle.hasSinks(s2)).toBe(false); 160 | expect(w.getPending()).toStrictEqual([]); 161 | expect(d).toBe(4); 162 | }); 163 | it('can unwatch multiple signals', async () => { 164 | const signals = [...Array(7)].map((_, i) => new Signal.State(i)); 165 | const notify = vi.fn(); 166 | const watcher = new Signal.subtle.Watcher(notify); 167 | const expectSources = (expected: typeof signals) => { 168 | const sources = Signal.subtle.introspectSources(watcher) as typeof signals; 169 | sources.sort((a, b) => signals.indexOf(a) - signals.indexOf(b)); 170 | expected.sort((a, b) => signals.indexOf(a) - signals.indexOf(b)); 171 | return expect(sources).toEqual(expected); 172 | }; 173 | 174 | watcher.watch(...signals); 175 | expectSources(signals); 176 | 177 | const unwatched = [0, 3, 4, 6].map((i) => signals[i]); 178 | const watched = signals.filter((s) => !unwatched.includes(s)); 179 | 180 | watcher.unwatch(...unwatched); 181 | expectSources(watched); 182 | 183 | let expectedNotifyCalls = 0; 184 | for (const signal of signals) { 185 | signal.set(signal.get() + 1); 186 | if (watched.includes(signal)) ++expectedNotifyCalls; 187 | 188 | expect(notify).toHaveBeenCalledTimes(expectedNotifyCalls); 189 | 190 | watcher.watch(); 191 | } 192 | }); 193 | }); 194 | -------------------------------------------------------------------------------- /tests/Signal/subtle/watcher.test.ts: -------------------------------------------------------------------------------- 1 | import {afterEach, describe, expect, it, vi} from 'vitest'; 2 | import {Signal} from '../../../src/wrapper.js'; 3 | 4 | describe('Watcher', () => { 5 | type Destructor = () => void; 6 | const notifySpy = vi.fn(); 7 | 8 | const watcher = new Signal.subtle.Watcher(() => { 9 | notifySpy(); 10 | }); 11 | 12 | function effect(cb: () => Destructor | void): () => void { 13 | let destructor: Destructor | void; 14 | const c = new Signal.Computed(() => (destructor = cb())); 15 | watcher.watch(c); 16 | c.get(); 17 | return () => { 18 | destructor?.(); 19 | watcher.unwatch(c); 20 | }; 21 | } 22 | 23 | function flushPending() { 24 | for (const signal of watcher.getPending()) { 25 | signal.get(); 26 | } 27 | expect(watcher.getPending()).toStrictEqual([]); 28 | } 29 | 30 | afterEach(() => watcher.unwatch(...Signal.subtle.introspectSources(watcher))); 31 | 32 | it('should work', () => { 33 | const watchedSpy = vi.fn(); 34 | const unwatchedSpy = vi.fn(); 35 | const stateSignal = new Signal.State(1, { 36 | [Signal.subtle.watched]: watchedSpy, 37 | [Signal.subtle.unwatched]: unwatchedSpy, 38 | }); 39 | 40 | stateSignal.set(100); 41 | stateSignal.set(5); 42 | 43 | const computedSignal = new Signal.Computed(() => stateSignal.get() * 2); 44 | 45 | let calls = 0; 46 | let output = 0; 47 | let computedOutput = 0; 48 | 49 | // Ensure the call backs are not called yet 50 | expect(watchedSpy).not.toHaveBeenCalled(); 51 | expect(unwatchedSpy).not.toHaveBeenCalled(); 52 | 53 | // Expect the watcher to not have any sources as nothing has been connected yet 54 | expect(Signal.subtle.introspectSources(watcher)).toHaveLength(0); 55 | expect(Signal.subtle.introspectSinks(computedSignal)).toHaveLength(0); 56 | expect(Signal.subtle.introspectSinks(stateSignal)).toHaveLength(0); 57 | 58 | expect(Signal.subtle.hasSinks(stateSignal)).toEqual(false); 59 | 60 | const destructor = effect(() => { 61 | output = stateSignal.get(); 62 | computedOutput = computedSignal.get(); 63 | calls++; 64 | return () => {}; 65 | }); 66 | 67 | // The signal is now watched 68 | expect(Signal.subtle.hasSinks(stateSignal)).toEqual(true); 69 | 70 | // Now that the effect is created, there will be a source 71 | expect(Signal.subtle.introspectSources(watcher)).toHaveLength(1); 72 | expect(Signal.subtle.introspectSinks(computedSignal)).toHaveLength(1); 73 | 74 | // Note: stateSignal has more sinks because one is for the computed signal and one is the effect. 75 | expect(Signal.subtle.introspectSinks(stateSignal)).toHaveLength(2); 76 | 77 | // Now the watched callback should be called 78 | expect(watchedSpy).toHaveBeenCalled(); 79 | expect(unwatchedSpy).not.toHaveBeenCalled(); 80 | 81 | // It should not have notified yet 82 | expect(notifySpy).not.toHaveBeenCalled(); 83 | 84 | stateSignal.set(10); 85 | 86 | // After a signal has been set, it should notify 87 | expect(notifySpy).toHaveBeenCalled(); 88 | 89 | // Initially, the effect should not have run 90 | expect(calls).toEqual(1); 91 | expect(output).toEqual(5); 92 | expect(computedOutput).toEqual(10); 93 | 94 | flushPending(); 95 | 96 | // The effect should run, and thus increment the value 97 | expect(calls).toEqual(2); 98 | expect(output).toEqual(10); 99 | expect(computedOutput).toEqual(20); 100 | 101 | // Kicking it off again, the effect should run again 102 | watcher.watch(); 103 | stateSignal.set(20); 104 | expect(watcher.getPending()).toHaveLength(1); 105 | flushPending(); 106 | 107 | // After a signal has been set, it should notify again 108 | expect(notifySpy).toHaveBeenCalledTimes(2); 109 | 110 | expect(calls).toEqual(3); 111 | expect(output).toEqual(20); 112 | expect(computedOutput).toEqual(40); 113 | 114 | Signal.subtle.untrack(() => { 115 | // Untrack doesn't affect set, only get 116 | stateSignal.set(999); 117 | expect(calls).toEqual(3); 118 | flushPending(); 119 | expect(calls).toEqual(4); 120 | }); 121 | 122 | // Destroy and un-subscribe 123 | destructor(); 124 | 125 | // Since now it is un-subscribed, it should now be called 126 | expect(unwatchedSpy).toHaveBeenCalled(); 127 | // We can confirm that it is un-watched by checking it 128 | expect(Signal.subtle.hasSinks(stateSignal)).toEqual(false); 129 | 130 | // Since now it is un-subscribed, this should have no effect now 131 | stateSignal.set(200); 132 | flushPending(); 133 | 134 | // Make sure that effect is no longer running 135 | // Everything should stay the same 136 | expect(calls).toEqual(4); 137 | expect(output).toEqual(999); 138 | expect(computedOutput).toEqual(1998); 139 | 140 | expect(watcher.getPending()).toHaveLength(0); 141 | 142 | // Adding any other effect after an unwatch should work as expected 143 | const destructor2 = effect(() => { 144 | output = stateSignal.get(); 145 | return () => {}; 146 | }); 147 | 148 | stateSignal.set(300); 149 | flushPending(); 150 | }); 151 | 152 | it('provides `this` to notify as normal function', () => { 153 | const mockGetPending = vi.fn(); 154 | 155 | const watcher = new Signal.subtle.Watcher(function () { 156 | this.getPending(); 157 | }); 158 | watcher.getPending = mockGetPending; 159 | 160 | const signal = new Signal.State(0); 161 | watcher.watch(signal); 162 | 163 | signal.set(1); 164 | expect(mockGetPending).toBeCalled(); 165 | }); 166 | 167 | it('can be closed in if needed in notify as an arrow function', () => { 168 | const mockGetPending = vi.fn(); 169 | 170 | const watcher = new Signal.subtle.Watcher(() => { 171 | watcher.getPending(); 172 | }); 173 | watcher.getPending = mockGetPending; 174 | 175 | const signal = new Signal.State(0); 176 | watcher.watch(signal); 177 | 178 | signal.set(1); 179 | expect(mockGetPending).toBeCalled(); 180 | }); 181 | 182 | it('should not break a computed signal to watch it before getting its value', () => { 183 | const signal = new Signal.State(0); 184 | const computedSignal = new Signal.Computed(() => signal.get()); 185 | const watcher = new Signal.subtle.Watcher(() => {}); 186 | expect(computedSignal.get()).toBe(0); 187 | signal.set(1); 188 | watcher.watch(computedSignal); 189 | expect(computedSignal.get()).toBe(1); 190 | watcher.unwatch(computedSignal); 191 | expect(computedSignal.get()).toBe(1); 192 | }); 193 | }); 194 | -------------------------------------------------------------------------------- /tests/behaviors/custom-equality.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it, vi} from 'vitest'; 2 | import {Signal} from '../../src/wrapper.js'; 3 | 4 | describe('Custom equality', () => { 5 | it('works for State', () => { 6 | let answer = true; 7 | const s = new Signal.State(1, { 8 | equals() { 9 | return answer; 10 | }, 11 | }); 12 | let n = 0; 13 | const c = new Signal.Computed(() => (n++, s.get())); 14 | 15 | expect(c.get()).toBe(1); 16 | expect(n).toBe(1); 17 | 18 | s.set(2); 19 | expect(s.get()).toBe(1); 20 | expect(c.get()).toBe(1); 21 | expect(n).toBe(1); 22 | 23 | answer = false; 24 | s.set(2); 25 | expect(s.get()).toBe(2); 26 | expect(c.get()).toBe(2); 27 | expect(n).toBe(2); 28 | 29 | s.set(2); 30 | expect(s.get()).toBe(2); 31 | expect(c.get()).toBe(2); 32 | expect(n).toBe(3); 33 | }); 34 | it('works for Computed', () => { 35 | let answer = true; 36 | let value = 1; 37 | const u = new Signal.State(1); 38 | const s = new Signal.Computed(() => (u.get(), value), { 39 | equals() { 40 | return answer; 41 | }, 42 | }); 43 | let n = 0; 44 | const c = new Signal.Computed(() => (n++, s.get())); 45 | 46 | expect(c.get()).toBe(1); 47 | expect(n).toBe(1); 48 | 49 | u.set(2); 50 | value = 2; 51 | expect(s.get()).toBe(1); 52 | expect(c.get()).toBe(1); 53 | expect(n).toBe(1); 54 | 55 | answer = false; 56 | u.set(3); 57 | expect(s.get()).toBe(2); 58 | expect(c.get()).toBe(2); 59 | expect(n).toBe(2); 60 | 61 | u.set(4); 62 | expect(s.get()).toBe(2); 63 | expect(c.get()).toBe(2); 64 | expect(n).toBe(3); 65 | }); 66 | it('does not leak tracking information', () => { 67 | const exact = new Signal.State(1); 68 | const epsilon = new Signal.State(0.1); 69 | const counter = new Signal.State(1); 70 | 71 | const cutoff = vi.fn((a, b) => Math.abs(a - b) < epsilon.get()); 72 | const innerFn = vi.fn(() => exact.get()); 73 | const inner = new Signal.Computed(innerFn, { 74 | equals: cutoff, 75 | }); 76 | 77 | const outerFn = vi.fn(() => { 78 | counter.get(); 79 | return inner.get(); 80 | }); 81 | const outer = new Signal.Computed(outerFn); 82 | 83 | outer.get(); 84 | 85 | // Everything runs the first time. 86 | expect(innerFn).toBeCalledTimes(1); 87 | expect(outerFn).toBeCalledTimes(1); 88 | 89 | exact.set(2); 90 | counter.set(2); 91 | outer.get(); 92 | 93 | // `outer` reruns because `counter` changed, `inner` reruns when called by 94 | // `outer`, and `cutoff` is called for the first time. 95 | expect(innerFn).toBeCalledTimes(2); 96 | expect(outerFn).toBeCalledTimes(2); 97 | expect(cutoff).toBeCalledTimes(1); 98 | 99 | epsilon.set(0.2); 100 | outer.get(); 101 | 102 | // Changing something `cutoff` depends on makes `inner` need to rerun, but 103 | // (since the new and old values are equal) not `outer`. 104 | expect(innerFn).toBeCalledTimes(3); 105 | expect(outerFn).toBeCalledTimes(2); 106 | expect(cutoff).toBeCalledTimes(2); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /tests/behaviors/cycles.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from 'vitest'; 2 | import {Signal} from '../../src/wrapper.js'; 3 | 4 | describe('Cycles', () => { 5 | it('detects trivial cycles', () => { 6 | const c = new Signal.Computed(() => c.get()); 7 | expect(() => c.get()).toThrow(); 8 | }); 9 | 10 | it('detects slightly larger cycles', () => { 11 | const c = new Signal.Computed(() => c2.get()); 12 | const c2 = new Signal.Computed(() => c.get()); 13 | const c3 = new Signal.Computed(() => c2.get()); 14 | expect(() => c3.get()).toThrow(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /tests/behaviors/dynamic-dependencies.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from 'vitest'; 2 | import {Signal} from '../../src/wrapper.js'; 3 | 4 | describe('Dynamic dependencies', () => { 5 | function run(live) { 6 | const states = Array.from('abcdefgh').map((s) => new Signal.State(s)); 7 | const sources = new Signal.State(states); 8 | const computed = new Signal.Computed(() => { 9 | let str = ''; 10 | for (const state of sources.get()) str += state.get(); 11 | return str; 12 | }); 13 | if (live) { 14 | const w = new Signal.subtle.Watcher(() => {}); 15 | w.watch(computed); 16 | } 17 | expect(computed.get()).toBe('abcdefgh'); 18 | expect(Signal.subtle.introspectSources(computed).slice(1)).toStrictEqual(states); 19 | 20 | sources.set(states.slice(0, 5)); 21 | expect(computed.get()).toBe('abcde'); 22 | expect(Signal.subtle.introspectSources(computed).slice(1)).toStrictEqual(states.slice(0, 5)); 23 | 24 | sources.set(states.slice(3)); 25 | expect(computed.get()).toBe('defgh'); 26 | expect(Signal.subtle.introspectSources(computed).slice(1)).toStrictEqual(states.slice(3)); 27 | } 28 | it('works live', () => run(true)); 29 | it('works not live', () => run(false)); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/behaviors/errors.test.ts: -------------------------------------------------------------------------------- 1 | import {afterEach, describe, expect, it, vi} from 'vitest'; 2 | import {Signal} from '../../src/wrapper.js'; 3 | 4 | describe('Errors', () => { 5 | it('are cached by computed signals', () => { 6 | const s = new Signal.State('first'); 7 | let n = 0; 8 | const c = new Signal.Computed(() => { 9 | n++; 10 | throw s.get(); 11 | }); 12 | let n2 = 0; 13 | const c2 = new Signal.Computed(() => { 14 | n2++; 15 | return c.get(); 16 | }); 17 | expect(n).toBe(0); 18 | expect(() => c.get()).toThrowError('first'); 19 | expect(() => c2.get()).toThrowError('first'); 20 | expect(n).toBe(1); 21 | expect(n2).toBe(1); 22 | expect(() => c.get()).toThrowError('first'); 23 | expect(() => c2.get()).toThrowError('first'); 24 | expect(n).toBe(1); 25 | expect(n2).toBe(1); 26 | s.set('second'); 27 | expect(() => c.get()).toThrowError('second'); 28 | expect(() => c2.get()).toThrowError('second'); 29 | expect(n).toBe(2); 30 | expect(n2).toBe(2); 31 | 32 | // Doesn't retrigger on setting state to the same value 33 | s.set('second'); 34 | expect(n).toBe(2); 35 | }); 36 | it('are cached by computed signals when watched', () => { 37 | const s = new Signal.State('first'); 38 | let n = 0; 39 | const c = new Signal.Computed(() => { 40 | n++; 41 | throw s.get(); 42 | }); 43 | const w = new Signal.subtle.Watcher(() => {}); 44 | w.watch(c); 45 | 46 | expect(n).toBe(0); 47 | expect(() => c.get()).toThrowError('first'); 48 | expect(n).toBe(1); 49 | expect(() => c.get()).toThrowError('first'); 50 | expect(n).toBe(1); 51 | s.set('second'); 52 | expect(() => c.get()).toThrowError('second'); 53 | expect(n).toBe(2); 54 | 55 | s.set('second'); 56 | expect(n).toBe(2); 57 | }); 58 | it('are cached by computed signals when equals throws', () => { 59 | const s = new Signal.State(0); 60 | const cSpy = vi.fn(() => s.get()); 61 | const c = new Signal.Computed(cSpy, { 62 | equals() { 63 | throw new Error('equals'); 64 | }, 65 | }); 66 | 67 | c.get(); 68 | s.set(1); 69 | 70 | // Error is cached; c throws again without needing to rerun. 71 | expect(() => c.get()).toThrowError('equals'); 72 | expect(cSpy).toBeCalledTimes(2); 73 | expect(() => c.get()).toThrowError('equals'); 74 | expect(cSpy).toBeCalledTimes(2); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /tests/behaviors/graph.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, it, vi, expect} from 'vitest'; 2 | import {Signal} from '../../src'; 3 | 4 | /** 5 | * SolidJS graph tests 6 | * 7 | * https://github.com/solidjs/signals/blob/main/tests/graph.test.ts 8 | */ 9 | 10 | describe('Graph', () => { 11 | it('should drop X->B->X updates', () => { 12 | // X 13 | // / | 14 | // A | <- Looks like a flag doesn't it? :D 15 | // \ | 16 | // B 17 | // | 18 | // C 19 | 20 | const $x = new Signal.State(2); 21 | 22 | const $a = new Signal.Computed(() => $x.get() - 1); 23 | const $b = new Signal.Computed(() => $x.get() + $a.get()); 24 | 25 | const compute = vi.fn(() => 'c: ' + $b.get()); 26 | const $c = new Signal.Computed(compute); 27 | 28 | expect($c.get()).toBe('c: 3'); 29 | expect(compute).toHaveBeenCalledTimes(1); 30 | compute.mockReset(); 31 | 32 | $x.set(4); 33 | $c.get(); 34 | expect(compute).toHaveBeenCalledTimes(1); 35 | }); 36 | 37 | it('should only update every signal once (diamond graph)', () => { 38 | // In this scenario "D" should only update once when "A" receive an update. This is sometimes 39 | // referred to as the "diamond" scenario. 40 | // X 41 | // / \ 42 | // A B 43 | // \ / 44 | // C 45 | 46 | const $x = new Signal.State('a'); 47 | const $a = new Signal.Computed(() => $x.get()); 48 | const $b = new Signal.Computed(() => $x.get()); 49 | 50 | const spy = vi.fn(() => $a.get() + ' ' + $b.get()); 51 | const $c = new Signal.Computed(spy); 52 | 53 | expect($c.get()).toBe('a a'); 54 | expect(spy).toHaveBeenCalledTimes(1); 55 | 56 | $x.set('aa'); 57 | expect($c.get()).toBe('aa aa'); 58 | expect(spy).toHaveBeenCalledTimes(2); 59 | }); 60 | 61 | it('should only update every signal once (diamond graph + tail)', () => { 62 | // "D" will be likely updated twice if our mark+sweep logic is buggy. 63 | // X 64 | // / \ 65 | // A B 66 | // \ / 67 | // C 68 | // | 69 | // D 70 | 71 | const $x = new Signal.State('a'); 72 | 73 | const $a = new Signal.Computed(() => $x.get()); 74 | const $b = new Signal.Computed(() => $x.get()); 75 | const $c = new Signal.Computed(() => $a.get() + ' ' + $b.get()); 76 | 77 | const spy = vi.fn(() => $c.get()); 78 | const $d = new Signal.Computed(spy); 79 | 80 | expect($d.get()).toBe('a a'); 81 | expect(spy).toHaveBeenCalledTimes(1); 82 | 83 | $x.set('aa'); 84 | expect($d.get()).toBe('aa aa'); 85 | expect(spy).toHaveBeenCalledTimes(2); 86 | }); 87 | 88 | it('should bail out if result is the same', () => { 89 | // Bail out if value of "A" never changes 90 | // X->A->B 91 | 92 | // const $x = new Signal.State('a'); 93 | const $x = new Signal.State('a'); 94 | 95 | const $a = new Signal.Computed(() => { 96 | $x.get(); 97 | return 'foo'; 98 | }); 99 | 100 | const spy = vi.fn(() => $a.get()); 101 | const $b = new Signal.Computed(spy); 102 | 103 | expect($b.get()).toBe('foo'); 104 | expect(spy).toHaveBeenCalledTimes(1); 105 | 106 | $x.set('aa'); 107 | expect($b.get()).toBe('foo'); 108 | expect(spy).toHaveBeenCalledTimes(1); 109 | }); 110 | 111 | it('should only update every signal once (jagged diamond graph + tails)', () => { 112 | // "E" and "F" will be likely updated >3 if our mark+sweep logic is buggy. 113 | // X 114 | // / \ 115 | // A B 116 | // | | 117 | // | C 118 | // \ / 119 | // D 120 | // / \ 121 | // E F 122 | 123 | const $x = new Signal.State('a'); 124 | 125 | const $a = new Signal.Computed(() => $x.get()); 126 | const $b = new Signal.Computed(() => $x.get()); 127 | const $c = new Signal.Computed(() => $b.get()); 128 | 129 | const dSpy = vi.fn(() => $a.get() + ' ' + $c.get()); 130 | const $d = new Signal.Computed(dSpy); 131 | 132 | const eSpy = vi.fn(() => $d.get()); 133 | const $e = new Signal.Computed(eSpy); 134 | const fSpy = vi.fn(() => $d.get()); 135 | const $f = new Signal.Computed(fSpy); 136 | 137 | expect($e.get()).toBe('a a'); 138 | expect(eSpy).toHaveBeenCalledTimes(1); 139 | 140 | expect($f.get()).toBe('a a'); 141 | expect(fSpy).toHaveBeenCalledTimes(1); 142 | 143 | $x.set('b'); 144 | 145 | expect($d.get()).toBe('b b'); 146 | expect(dSpy).toHaveBeenCalledTimes(2); 147 | 148 | expect($e.get()).toBe('b b'); 149 | expect(eSpy).toHaveBeenCalledTimes(2); 150 | 151 | expect($f.get()).toBe('b b'); 152 | expect(fSpy).toHaveBeenCalledTimes(2); 153 | 154 | $x.set('c'); 155 | 156 | expect($d.get()).toBe('c c'); 157 | expect(dSpy).toHaveBeenCalledTimes(3); 158 | 159 | expect($e.get()).toBe('c c'); 160 | expect(eSpy).toHaveBeenCalledTimes(3); 161 | 162 | expect($f.get()).toBe('c c'); 163 | expect(fSpy).toHaveBeenCalledTimes(3); 164 | }); 165 | 166 | it('should ensure subs update even if one dep is static', () => { 167 | // X 168 | // / \ 169 | // A *B <- returns same value every time 170 | // \ / 171 | // C 172 | 173 | const $x = new Signal.State('a'); 174 | 175 | const $a = new Signal.Computed(() => $x.get()); 176 | const $b = new Signal.Computed(() => { 177 | $x.get(); 178 | return 'c'; 179 | }); 180 | 181 | const spy = vi.fn(() => $a.get() + ' ' + $b.get()); 182 | const $c = new Signal.Computed(spy); 183 | 184 | expect($c.get()).toBe('a c'); 185 | 186 | $x.set('aa'); 187 | 188 | expect($c.get()).toBe('aa c'); 189 | expect(spy).toHaveBeenCalledTimes(2); 190 | }); 191 | 192 | it('should ensure subs update even if two deps mark it clean', () => { 193 | // In this scenario both "B" and "C" always return the same value. But "D" must still update 194 | // because "X" marked it. If "D" isn't updated, then we have a bug. 195 | // X 196 | // / | \ 197 | // A *B *C 198 | // \ | / 199 | // D 200 | 201 | const $x = new Signal.State('a'); 202 | 203 | const $b = new Signal.Computed(() => $x.get()); 204 | const $c = new Signal.Computed(() => { 205 | $x.get(); 206 | return 'c'; 207 | }); 208 | const $d = new Signal.Computed(() => { 209 | $x.get(); 210 | return 'd'; 211 | }); 212 | 213 | const spy = vi.fn(() => $b.get() + ' ' + $c.get() + ' ' + $d.get()); 214 | const $e = new Signal.Computed(spy); 215 | 216 | expect($e.get()).toBe('a c d'); 217 | 218 | $x.set('aa'); 219 | 220 | expect($e.get()).toBe('aa c d'); 221 | expect(spy).toHaveBeenCalledTimes(2); 222 | }); 223 | 224 | it('propagates in topological order', () => { 225 | // 226 | // c1 227 | // / \ 228 | // / \ 229 | // b1 b2 230 | // \ / 231 | // \ / 232 | // a1 233 | // 234 | var seq = '', 235 | a1 = new Signal.State(false), 236 | b1 = new Signal.Computed( 237 | () => { 238 | a1.get(); 239 | seq += 'b1'; 240 | }, 241 | {equals: () => false}, 242 | ), 243 | b2 = new Signal.Computed( 244 | () => { 245 | a1.get(); 246 | seq += 'b2'; 247 | }, 248 | {equals: () => false}, 249 | ), 250 | c1 = new Signal.Computed( 251 | () => { 252 | b1.get(), b2.get(); 253 | seq += 'c1'; 254 | }, 255 | {equals: () => false}, 256 | ); 257 | 258 | c1.get(); 259 | seq = ''; 260 | a1.set(true); 261 | c1.get(); 262 | expect(seq).toBe('b1b2c1'); 263 | }); 264 | 265 | it('only propagates once with linear convergences', () => { 266 | // d 267 | // | 268 | // +---+---+---+---+ 269 | // v v v v v 270 | // f1 f2 f3 f4 f5 271 | // | | | | | 272 | // +---+---+---+---+ 273 | // v 274 | // g 275 | var d = new Signal.State(0), 276 | f1 = new Signal.Computed(() => d.get()), 277 | f2 = new Signal.Computed(() => d.get()), 278 | f3 = new Signal.Computed(() => d.get()), 279 | f4 = new Signal.Computed(() => d.get()), 280 | f5 = new Signal.Computed(() => d.get()), 281 | gcount = 0, 282 | g = new Signal.Computed(() => { 283 | gcount++; 284 | return f1.get() + f2.get() + f3.get() + f4.get() + f5.get(); 285 | }); 286 | 287 | g.get(); 288 | gcount = 0; 289 | d.set(1); 290 | g.get(); 291 | expect(gcount).toBe(1); 292 | }); 293 | 294 | it('only propagates once with exponential convergence', () => { 295 | // d 296 | // | 297 | // +---+---+ 298 | // v v v 299 | // f1 f2 f3 300 | // \ | / 301 | // O 302 | // / | \ 303 | // v v v 304 | // g1 g2 g3 305 | // +---+---+ 306 | // v 307 | // h 308 | var d = new Signal.State(0), 309 | f1 = new Signal.Computed(() => { 310 | return d.get(); 311 | }), 312 | f2 = new Signal.Computed(() => { 313 | return d.get(); 314 | }), 315 | f3 = new Signal.Computed(() => { 316 | return d.get(); 317 | }), 318 | g1 = new Signal.Computed(() => { 319 | return f1.get() + f2.get() + f3.get(); 320 | }), 321 | g2 = new Signal.Computed(() => { 322 | return f1.get() + f2.get() + f3.get(); 323 | }), 324 | g3 = new Signal.Computed(() => { 325 | return f1.get() + f2.get() + f3.get(); 326 | }), 327 | hcount = 0, 328 | h = new Signal.Computed(() => { 329 | hcount++; 330 | return g1.get() + g2.get() + g3.get(); 331 | }); 332 | h.get(); 333 | hcount = 0; 334 | d.set(1); 335 | h.get(); 336 | expect(hcount).toBe(1); 337 | }); 338 | 339 | it('does not trigger downstream computations unless changed', () => { 340 | const s1 = new Signal.State(1, {equals: () => false}); 341 | let order = ''; 342 | const t1 = new Signal.Computed(() => { 343 | order += 't1'; 344 | return s1.get(); 345 | }); 346 | const t2 = new Signal.Computed(() => { 347 | order += 'c1'; 348 | t1.get(); 349 | }); 350 | t2.get(); 351 | expect(order).toBe('c1t1'); 352 | order = ''; 353 | s1.set(1); 354 | t2.get(); 355 | expect(order).toBe('t1'); 356 | order = ''; 357 | s1.set(2); 358 | t2.get(); 359 | expect(order).toBe('t1c1'); 360 | }); 361 | 362 | it('applies updates to changed dependees in same order as new Signal.Computed', () => { 363 | const s1 = new Signal.State(0); 364 | let order = ''; 365 | const t1 = new Signal.Computed(() => { 366 | order += 't1'; 367 | return s1.get() === 0; 368 | }); 369 | const t2 = new Signal.Computed(() => { 370 | order += 'c1'; 371 | return s1.get(); 372 | }); 373 | const t3 = new Signal.Computed(() => { 374 | order += 'c2'; 375 | return t1.get(); 376 | }); 377 | t2.get(); 378 | t3.get(); 379 | expect(order).toBe('c1c2t1'); 380 | order = ''; 381 | s1.set(1); 382 | t2.get(); 383 | t3.get(); 384 | expect(order).toBe('c1t1c2'); 385 | }); 386 | 387 | it('updates downstream pending computations', () => { 388 | const s1 = new Signal.State(0); 389 | const s2 = new Signal.State(0); 390 | let order = ''; 391 | const t1 = new Signal.Computed(() => { 392 | order += 't1'; 393 | return s1.get() === 0; 394 | }); 395 | const t2 = new Signal.Computed(() => { 396 | order += 'c1'; 397 | return s1.get(); 398 | }); 399 | const t3 = new Signal.Computed(() => { 400 | order += 'c2'; 401 | t1.get(); 402 | return new Signal.Computed(() => { 403 | order += 'c2_1'; 404 | return s2.get(); 405 | }); 406 | }); 407 | order = ''; 408 | s1.set(1); 409 | t2.get(); 410 | t3.get().get(); 411 | expect(order).toBe('c1c2t1c2_1'); 412 | }); 413 | 414 | describe('with changing dependencies', () => { 415 | let i: {get: () => boolean; set: (v: boolean) => void}; 416 | let t: {get: () => number; set: (v: number) => void}; 417 | let e: {get: () => number; set: (v: number) => void}; 418 | let fevals: number; 419 | let f: {get: () => number}; 420 | 421 | function init() { 422 | i = new Signal.State(true); 423 | t = new Signal.State(1); 424 | e = new Signal.State(2); 425 | fevals = 0; 426 | f = new Signal.Computed(() => { 427 | fevals++; 428 | return i.get() ? t.get() : e.get(); 429 | }); 430 | f.get(); 431 | fevals = 0; 432 | } 433 | 434 | it('updates on active dependencies', () => { 435 | init(); 436 | t.set(5); 437 | expect(f.get()).toBe(5); 438 | expect(fevals).toBe(1); 439 | }); 440 | 441 | it('does not update on inactive dependencies', () => { 442 | init(); 443 | e.set(5); 444 | expect(f.get()).toBe(1); 445 | expect(fevals).toBe(0); 446 | }); 447 | 448 | it('deactivates obsolete dependencies', () => { 449 | init(); 450 | i.set(false); 451 | f.get(); 452 | fevals = 0; 453 | t.set(5); 454 | f.get(); 455 | expect(fevals).toBe(0); 456 | }); 457 | 458 | it('activates new dependencies', () => { 459 | init(); 460 | i.set(false); 461 | fevals = 0; 462 | e.set(5); 463 | f.get(); 464 | expect(fevals).toBe(1); 465 | }); 466 | 467 | it('ensures that new dependencies are updated before dependee', () => { 468 | var order = '', 469 | a = new Signal.State(0), 470 | b = new Signal.Computed(() => { 471 | order += 'b'; 472 | return a.get() + 1; 473 | }), 474 | c = new Signal.Computed(() => { 475 | order += 'c'; 476 | const check = b.get(); 477 | if (check) { 478 | return check; 479 | } 480 | return e.get(); 481 | }), 482 | d = new Signal.Computed(() => { 483 | return a.get(); 484 | }), 485 | e = new Signal.Computed(() => { 486 | order += 'd'; 487 | return d.get() + 10; 488 | }); 489 | 490 | c.get(); 491 | e.get(); 492 | expect(order).toBe('cbd'); 493 | 494 | order = ''; 495 | a.set(-1); 496 | c.get(); 497 | e.get(); 498 | 499 | expect(order).toBe('bcd'); 500 | expect(c.get()).toBe(9); 501 | 502 | order = ''; 503 | a.set(0); 504 | c.get(); 505 | e.get(); 506 | expect(order).toBe('bcd'); 507 | expect(c.get()).toBe(1); 508 | }); 509 | }); 510 | 511 | it('does not update subsequent pending computations after stale invocations', () => { 512 | const s1 = new Signal.State(1); 513 | const s2 = new Signal.State(false); 514 | let count = 0; 515 | /* 516 | s1 517 | | 518 | +---+---+ 519 | t1 t2 c1 t3 520 | \ / 521 | c3 522 | [PN,PN,STL,void] 523 | */ 524 | const t1 = new Signal.Computed(() => s1.get() > 0); 525 | const t2 = new Signal.Computed(() => s1.get() > 0); 526 | const c1 = new Signal.Computed(() => s1.get()); 527 | const t3 = new Signal.Computed(() => { 528 | const a = s1.get(); 529 | const b = s2.get(); 530 | return a && b; 531 | }); 532 | const c3 = new Signal.Computed(() => { 533 | t1.get(); 534 | t2.get(); 535 | c1.get(); 536 | t3.get(); 537 | count++; 538 | }); 539 | c3.get(); 540 | s2.set(true); 541 | c3.get(); 542 | expect(count).toBe(2); 543 | s1.set(2); 544 | c3.get(); 545 | expect(count).toBe(3); 546 | }); 547 | 548 | it('evaluates stale computations before dependees when trackers stay unchanged', () => { 549 | let s1 = new Signal.State(1, {equals: () => false}); 550 | let order = ''; 551 | let t1 = new Signal.Computed(() => { 552 | order += 't1'; 553 | return s1.get() > 2; 554 | }); 555 | let t2 = new Signal.Computed(() => { 556 | order += 't2'; 557 | return s1.get() > 2; 558 | }); 559 | let c1 = new Signal.Computed( 560 | () => { 561 | order += 'c1'; 562 | s1.get(); 563 | }, 564 | { 565 | equals: () => false, 566 | }, 567 | ); 568 | const c2 = new Signal.Computed(() => { 569 | order += 'c2'; 570 | t1.get(); 571 | t2.get(); 572 | c1.get(); 573 | }); 574 | c2.get(); 575 | order = ''; 576 | s1.set(1); 577 | c2.get(); 578 | expect(order).toBe('t1t2c1c2'); 579 | order = ''; 580 | s1.set(3); 581 | c2.get(); 582 | expect(order).toBe('t1c2t2c1'); 583 | }); 584 | 585 | it('correctly marks downstream computations as stale on change', () => { 586 | const s1 = new Signal.State(1); 587 | let order = ''; 588 | const t1 = new Signal.Computed(() => { 589 | order += 't1'; 590 | return s1.get(); 591 | }); 592 | const c1 = new Signal.Computed(() => { 593 | order += 'c1'; 594 | return t1.get(); 595 | }); 596 | const c2 = new Signal.Computed(() => { 597 | order += 'c2'; 598 | return c1.get(); 599 | }); 600 | const c3 = new Signal.Computed(() => { 601 | order += 'c3'; 602 | return c2.get(); 603 | }); 604 | c3.get(); 605 | order = ''; 606 | s1.set(2); 607 | c3.get(); 608 | expect(order).toBe('t1c1c2c3'); 609 | }); 610 | 611 | // https://github.com/preactjs/signals/blob/main/packages/core/test/signal.test.tsx#L1706 612 | it('should not update a sub if all deps unmark it', () => { 613 | // In this scenario "B" and "C" always return the same value. When "A" 614 | // changes, "D" should not update. 615 | // A 616 | // / \ 617 | // *B *C 618 | // \ / 619 | // D 620 | 621 | const a = new Signal.State('a'); 622 | const b = new Signal.Computed(() => { 623 | a.get(); 624 | return 'b'; 625 | }); 626 | const c = new Signal.Computed(() => { 627 | a.get(); 628 | return 'c'; 629 | }); 630 | const spy = vi.fn(() => b.get() + ' ' + c.get()); 631 | const d = new Signal.Computed(spy); 632 | 633 | expect(d.get()).toBe('b c'); 634 | spy.mockReset(); 635 | 636 | a.set('aa'); 637 | expect(spy).not.toHaveBeenCalled(); 638 | }); 639 | }); 640 | -------------------------------------------------------------------------------- /tests/behaviors/guards.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from 'vitest'; 2 | import {Signal} from '../../src/wrapper.js'; 3 | 4 | describe('Guards', () => { 5 | it('should work with Signals', () => { 6 | const state = new Signal.State(1); 7 | const computed = new Signal.Computed(() => state.get() * 2); 8 | expect(Signal.isState(state)).toBe(true); 9 | expect(Signal.isComputed(state)).toBe(false); 10 | 11 | expect(Signal.isState(computed)).toBe(false); 12 | expect(Signal.isComputed(computed)).toBe(true); 13 | }); 14 | 15 | it("shouldn't error with values", () => { 16 | expect(Signal.isState(1)).toBe(false); 17 | expect(Signal.isComputed(2)).toBe(false); 18 | 19 | expect(Signal.isState({})).toBe(false); 20 | expect(Signal.isComputed({})).toBe(false); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /tests/behaviors/liveness.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it, vi} from 'vitest'; 2 | import {Signal} from '../../src/wrapper.js'; 3 | 4 | describe('liveness', () => { 5 | it('only changes on first and last descendant', () => { 6 | const watchedSpy = vi.fn(); 7 | const unwatchedSpy = vi.fn(); 8 | const state = new Signal.State(1, { 9 | [Signal.subtle.watched]: watchedSpy, 10 | [Signal.subtle.unwatched]: unwatchedSpy, 11 | }); 12 | const computed = new Signal.Computed(() => state.get()); 13 | computed.get(); 14 | expect(watchedSpy).not.toBeCalled(); 15 | expect(unwatchedSpy).not.toBeCalled(); 16 | 17 | const w = new Signal.subtle.Watcher(() => {}); 18 | const w2 = new Signal.subtle.Watcher(() => {}); 19 | 20 | w.watch(computed); 21 | expect(watchedSpy).toBeCalledTimes(1); 22 | expect(unwatchedSpy).not.toBeCalled(); 23 | 24 | w2.watch(computed); 25 | expect(watchedSpy).toBeCalledTimes(1); 26 | expect(unwatchedSpy).not.toBeCalled(); 27 | 28 | w2.unwatch(computed); 29 | expect(watchedSpy).toBeCalledTimes(1); 30 | expect(unwatchedSpy).not.toBeCalled(); 31 | 32 | w.unwatch(computed); 33 | expect(watchedSpy).toBeCalledTimes(1); 34 | expect(unwatchedSpy).toBeCalledTimes(1); 35 | }); 36 | 37 | it('is tracked well on computed signals', () => { 38 | const watchedSpy = vi.fn(); 39 | const unwatchedSpy = vi.fn(); 40 | const s = new Signal.State(1); 41 | const c = new Signal.Computed(() => s.get(), { 42 | [Signal.subtle.watched]: watchedSpy, 43 | [Signal.subtle.unwatched]: unwatchedSpy, 44 | }); 45 | 46 | c.get(); 47 | expect(watchedSpy).not.toBeCalled(); 48 | expect(unwatchedSpy).not.toBeCalled(); 49 | 50 | const w = new Signal.subtle.Watcher(() => {}); 51 | w.watch(c); 52 | expect(watchedSpy).toBeCalledTimes(1); 53 | expect(unwatchedSpy).not.toBeCalled(); 54 | 55 | w.unwatch(c); 56 | expect(watchedSpy).toBeCalledTimes(1); 57 | expect(unwatchedSpy).toBeCalledTimes(1); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /tests/behaviors/prohibited-contexts.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from 'vitest'; 2 | import {Signal} from '../../src/wrapper.js'; 3 | 4 | describe('Prohibited contexts', () => { 5 | it('allows writes during computed', () => { 6 | const s = new Signal.State(1); 7 | const c = new Signal.Computed(() => (s.set(s.get() + 1), s.get())); 8 | expect(c.get()).toBe(2); 9 | expect(s.get()).toBe(2); 10 | 11 | // Note: c is marked clean in this case, even though re-evaluating it 12 | // would cause it to change value (due to the set inside of it). 13 | expect(c.get()).toBe(2); 14 | expect(s.get()).toBe(2); 15 | 16 | s.set(3); 17 | 18 | expect(c.get()).toBe(4); 19 | expect(s.get()).toBe(4); 20 | }); 21 | it('disallows reads and writes during watcher notify', () => { 22 | const s = new Signal.State(1); 23 | const w = new Signal.subtle.Watcher(() => { 24 | s.get(); 25 | }); 26 | w.watch(s); 27 | expect(() => s.set(2)).toThrow(); 28 | w.unwatch(s); 29 | expect(() => s.set(3)).not.toThrow(); 30 | 31 | const w2 = new Signal.subtle.Watcher(() => { 32 | s.set(4); 33 | }); 34 | w2.watch(s); 35 | expect(() => s.set(5)).toThrow(); 36 | w2.unwatch(s); 37 | expect(() => s.set(3)).not.toThrow(); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /tests/behaviors/pruning.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from 'vitest'; 2 | import {Signal} from '../../src/wrapper.js'; 3 | 4 | describe('Pruning', () => { 5 | it('only recalculates until things are equal', () => { 6 | const s = new Signal.State(0); 7 | let n = 0; 8 | const c = new Signal.Computed(() => (n++, s.get())); 9 | let n2 = 0; 10 | const c2 = new Signal.Computed(() => (n2++, c.get(), 5)); 11 | let n3 = 0; 12 | const c3 = new Signal.Computed(() => (n3++, c2.get())); 13 | 14 | expect(n).toBe(0); 15 | expect(n2).toBe(0); 16 | expect(n3).toBe(0); 17 | 18 | expect(c3.get()).toBe(5); 19 | expect(n).toBe(1); 20 | expect(n2).toBe(1); 21 | expect(n3).toBe(1); 22 | 23 | s.set(1); 24 | expect(n).toBe(1); 25 | expect(n2).toBe(1); 26 | expect(n3).toBe(1); 27 | 28 | expect(c3.get()).toBe(5); 29 | expect(n).toBe(2); 30 | expect(n2).toBe(2); 31 | expect(n3).toBe(1); 32 | }); 33 | it('does similar pruning for live signals', () => { 34 | const s = new Signal.State(0); 35 | let n = 0; 36 | const c = new Signal.Computed(() => (n++, s.get())); 37 | let n2 = 0; 38 | const c2 = new Signal.Computed(() => (n2++, c.get(), 5)); 39 | let n3 = 0; 40 | const c3 = new Signal.Computed(() => (n3++, c2.get())); 41 | const w = new Signal.subtle.Watcher(() => {}); 42 | w.watch(c3); 43 | 44 | expect(n).toBe(0); 45 | expect(n2).toBe(0); 46 | expect(n3).toBe(0); 47 | 48 | expect(c3.get()).toBe(5); 49 | expect(n).toBe(1); 50 | expect(n2).toBe(1); 51 | expect(n3).toBe(1); 52 | 53 | s.set(1); 54 | expect(n).toBe(1); 55 | expect(n2).toBe(1); 56 | expect(n3).toBe(1); 57 | 58 | expect(w.getPending().length).toBe(1); 59 | 60 | expect(c3.get()).toBe(5); 61 | expect(n).toBe(2); 62 | expect(n2).toBe(2); 63 | expect(n3).toBe(1); 64 | 65 | expect(w.getPending().length).toBe(0); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /tests/behaviors/receivers.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from 'vitest'; 2 | import {Signal} from '../../src/wrapper.js'; 3 | 4 | describe('Receivers', () => { 5 | it('is this for computed', () => { 6 | let receiver; 7 | const c = new Signal.Computed(function () { 8 | receiver = this; 9 | }); 10 | expect(c.get()).toBe(undefined); 11 | expect(receiver).toBe(c); 12 | }); 13 | it('is this for watched/unwatched', () => { 14 | let r1, r2; 15 | const s = new Signal.State(1, { 16 | [Signal.subtle.watched]() { 17 | r1 = this; 18 | }, 19 | [Signal.subtle.unwatched]() { 20 | r2 = this; 21 | }, 22 | }); 23 | expect(r1).toBe(undefined); 24 | expect(r2).toBe(undefined); 25 | const w = new Signal.subtle.Watcher(() => {}); 26 | w.watch(s); 27 | expect(r1).toBe(s); 28 | expect(r2).toBe(undefined); 29 | w.unwatch(s); 30 | expect(r2).toBe(s); 31 | }); 32 | it('is this for equals', () => { 33 | let receiver; 34 | const options = { 35 | equals() { 36 | receiver = this; 37 | return false; 38 | }, 39 | }; 40 | const s = new Signal.State(1, options); 41 | s.set(2); 42 | expect(receiver).toBe(s); 43 | 44 | const c = new Signal.Computed(() => s.get(), options); 45 | expect(c.get()).toBe(2); 46 | s.set(4); 47 | expect(c.get()).toBe(4); 48 | expect(receiver).toBe(c); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /tests/behaviors/type-checking.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from 'vitest'; 2 | import {Signal} from '../../src/wrapper.js'; 3 | 4 | describe('Expected class shape', () => { 5 | it('should be on the prototype', () => { 6 | expect(typeof Signal.State.prototype.get).toBe('function'); 7 | expect(typeof Signal.State.prototype.set).toBe('function'); 8 | expect(typeof Signal.Computed.prototype.get).toBe('function'); 9 | expect(typeof Signal.subtle.Watcher.prototype.watch).toBe('function'); 10 | expect(typeof Signal.subtle.Watcher.prototype.unwatch).toBe('function'); 11 | expect(typeof Signal.subtle.Watcher.prototype.getPending).toBe('function'); 12 | }); 13 | }); 14 | 15 | describe('type checks', () => { 16 | it('checks types in methods', () => { 17 | let x = {}; 18 | let s = new Signal.State(1); 19 | let c = new Signal.Computed(() => {}); 20 | let w = new Signal.subtle.Watcher(() => {}); 21 | 22 | expect(() => Signal.State.prototype.get.call(x)).toThrowError(TypeError); 23 | expect(Signal.State.prototype.get.call(s)).toBe(1); 24 | expect(() => Signal.State.prototype.get.call(c)).toThrowError(TypeError); 25 | expect(() => Signal.State.prototype.get.call(w)).toThrowError(TypeError); 26 | 27 | expect(() => Signal.State.prototype.set.call(x, 2)).toThrowError(TypeError); 28 | expect(Signal.State.prototype.set.call(s, 2)).toBe(undefined); 29 | expect(() => Signal.State.prototype.set.call(c, 2)).toThrowError(TypeError); 30 | expect(() => Signal.State.prototype.set.call(w, 2)).toThrowError(TypeError); 31 | 32 | expect(() => Signal.Computed.prototype.get.call(x)).toThrowError(TypeError); 33 | expect(() => Signal.Computed.prototype.get.call(s)).toThrowError(TypeError); 34 | expect(Signal.Computed.prototype.get.call(c)).toBe(undefined); 35 | expect(() => Signal.Computed.prototype.get.call(w)).toThrowError(TypeError); 36 | 37 | expect(() => Signal.subtle.Watcher.prototype.watch.call(x, s)).toThrowError(TypeError); 38 | expect(() => Signal.subtle.Watcher.prototype.watch.call(s, s)).toThrowError(TypeError); 39 | expect(() => Signal.subtle.Watcher.prototype.watch.call(c, s)).toThrowError(TypeError); 40 | expect(Signal.subtle.Watcher.prototype.watch.call(w, s)).toBe(undefined); 41 | expect(() => Signal.subtle.Watcher.prototype.watch.call(w, w)).toThrowError(TypeError); 42 | 43 | expect(() => Signal.subtle.Watcher.prototype.unwatch.call(x, s)).toThrowError(TypeError); 44 | expect(() => Signal.subtle.Watcher.prototype.unwatch.call(s, s)).toThrowError(TypeError); 45 | expect(() => Signal.subtle.Watcher.prototype.unwatch.call(c, s)).toThrowError(TypeError); 46 | expect(Signal.subtle.Watcher.prototype.unwatch.call(w, s)).toBe(undefined); 47 | expect(() => Signal.subtle.Watcher.prototype.unwatch.call(w, w)).toThrowError(TypeError); 48 | 49 | expect(() => Signal.subtle.Watcher.prototype.getPending.call(x, s)).toThrowError(TypeError); 50 | expect(() => Signal.subtle.Watcher.prototype.getPending.call(s, s)).toThrowError(TypeError); 51 | expect(() => Signal.subtle.Watcher.prototype.getPending.call(c, s)).toThrowError(TypeError); 52 | expect(Signal.subtle.Watcher.prototype.getPending.call(w, s)).toStrictEqual([]); 53 | 54 | // @ts-expect-error 55 | expect(() => Signal.subtle.introspectSources(x)).toThrowError(TypeError); 56 | // @ts-expect-error 57 | expect(() => Signal.subtle.introspectSources(s)).toThrowError(TypeError); 58 | expect(Signal.subtle.introspectSources(c)).toStrictEqual([]); 59 | expect(Signal.subtle.introspectSources(w)).toStrictEqual([]); 60 | 61 | // @ts-expect-error 62 | expect(() => Signal.subtle.hasSinks(x)).toThrowError(TypeError); 63 | expect(Signal.subtle.hasSinks(s)).toBe(false); 64 | expect(Signal.subtle.hasSinks(c)).toBe(false); 65 | // @ts-expect-error 66 | expect(() => Signal.subtle.hasSinks(w)).toThrowError(TypeError); 67 | 68 | // @ts-expect-error 69 | expect(() => Signal.subtle.introspectSinks(x)).toThrowError(TypeError); 70 | expect(Signal.subtle.introspectSinks(s)).toStrictEqual([]); 71 | expect(Signal.subtle.introspectSinks(c)).toStrictEqual([]); 72 | // @ts-expect-error 73 | expect(() => Signal.subtle.introspectSinks(w)).toThrowError(TypeError); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /tests/benchmarks/adapter.ts: -------------------------------------------------------------------------------- 1 | import {ReactiveFramework} from 'js-reactivity-benchmark'; 2 | import {Signal} from '../../src'; 3 | 4 | export const tc39SignalsProposalStage0: ReactiveFramework = { 5 | name: 'TC39 Signals Polyfill', 6 | signal: (initialValue) => { 7 | const s = new Signal.State(initialValue); 8 | return { 9 | write: (v) => s.set(v), 10 | read: () => s.get(), 11 | }; 12 | }, 13 | computed: (fn) => { 14 | const c = new Signal.Computed(fn); 15 | return { 16 | read: () => c.get(), 17 | }; 18 | }, 19 | effect: (fn) => effect(fn), 20 | withBatch: (fn) => { 21 | fn(); 22 | processPending(); 23 | }, 24 | withBuild: (fn) => fn(), 25 | }; 26 | 27 | let needsEnqueue = false; 28 | 29 | const w = new Signal.subtle.Watcher(() => { 30 | if (needsEnqueue) { 31 | needsEnqueue = false; 32 | (async () => { 33 | await Promise.resolve(); 34 | // next micro queue 35 | processPending(); 36 | })(); 37 | } 38 | }); 39 | 40 | function processPending() { 41 | needsEnqueue = true; 42 | 43 | for (const s of w.getPending()) { 44 | s.get(); 45 | } 46 | 47 | w.watch(); 48 | } 49 | 50 | export function effect(callback: any) { 51 | let cleanup: any; 52 | 53 | const computed = new Signal.Computed(() => { 54 | typeof cleanup === 'function' && cleanup(); 55 | cleanup = callback(); 56 | }); 57 | 58 | w.watch(computed); 59 | computed.get(); 60 | 61 | return () => { 62 | w.unwatch(computed); 63 | typeof cleanup === 'function' && cleanup(); 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /tests/benchmarks/benchmarks.ts: -------------------------------------------------------------------------------- 1 | import {testFramework, logPerfHeaders, logPerfResult} from 'js-reactivity-benchmark'; 2 | import {tc39SignalsProposalStage0} from './adapter'; 3 | import {writeFile} from 'fs/promises'; 4 | 5 | (async () => { 6 | logPerfHeaders(); 7 | const results: {name: string; value: number; unit: string}[] = []; 8 | await testFramework({framework: tc39SignalsProposalStage0, testPullCounts: true}, (result) => { 9 | logPerfResult(result); 10 | results.push({name: result.test, value: result.time, unit: 'ms'}); 11 | }); 12 | await writeFile('benchmarks.json', JSON.stringify(results, null, ' ')); 13 | })(); 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "rootDir": "src", 5 | "pretty": true, 6 | "moduleResolution": "node", 7 | "module": "ESNext", 8 | "target": "ES2022", 9 | "sourceMap": true, 10 | "declaration": true, 11 | "declarationMap": true, 12 | "noEmitOnError": false, 13 | "lib": ["DOM", "ES2021"], 14 | "strict": true, 15 | "composite": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "allowImportingTsExtensions": true 18 | }, 19 | "exclude": ["**/node_modules/**", "**/*.spec.ts", "**/dist/**/*"], 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import {dirname, join} from 'node:path'; 2 | import {fileURLToPath} from 'node:url'; 3 | import {defineConfig} from 'vite'; 4 | import dts from 'vite-plugin-dts'; 5 | 6 | const entry = join(dirname(fileURLToPath(import.meta.url)), './src/index.ts'); 7 | 8 | export default defineConfig({ 9 | plugins: [dts()], 10 | build: { 11 | minify: false, 12 | lib: { 13 | entry, 14 | formats: ['es'], 15 | fileName: 'index', 16 | }, 17 | }, 18 | }); 19 | --------------------------------------------------------------------------------