├── .github └── workflows │ ├── ci.yml │ ├── plan-release.yml │ └── publish.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .release-plan.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── RELEASE.md ├── babel.config.json ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── src ├── -private │ └── util.ts ├── array-map.ts ├── array.ts ├── async-computed.ts ├── async-data.ts ├── async-function.ts ├── dedupe.ts ├── deep.ts ├── index.ts ├── local-copy.ts ├── map.ts ├── object.ts ├── promise.ts ├── set.ts ├── subtle │ ├── batched-effect.ts │ ├── microtask-effect.ts │ └── reaction.ts ├── weak-map.ts └── weak-set.ts ├── tests-public ├── babel.config.json ├── package.json ├── public-api.test.ts ├── tsconfig.json └── vite.config.ts ├── tests ├── @localCopy.test.ts ├── @signal.test.ts ├── array-map.test.ts ├── array.test.ts ├── async-computed.test.ts ├── async-data.test.ts ├── async-function.test.ts ├── deep.test.ts ├── helpers.ts ├── local-copy.test.ts ├── map.test.ts ├── object.test.ts ├── set.test.ts ├── subtle │ ├── batched-effect.test.ts │ ├── microtask-effect.test.ts │ └── reaction.test.ts ├── weak-map.test.ts └── weak-set.test.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 | lint: 15 | name: "Lint" 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 5 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: wyvox/action-setup-pnpm@v3 21 | - run: pnpm install 22 | - run: pnpm build 23 | - run: pnpm lint 24 | - run: pnpm lint 25 | working-directory: tests-public 26 | 27 | test: 28 | name: "Test ${{ matrix.testenv.name }}" 29 | runs-on: ubuntu-latest 30 | timeout-minutes: 5 31 | strategy: 32 | matrix: 33 | testenv: 34 | - { name: "Node", args: '' } 35 | - { name: "Chrome", args: '--browser.name=chrome --browser.headless' } 36 | - { name: "Firefox", args: '--browser.name=firefox --browser.headless' } 37 | 38 | steps: 39 | - uses: actions/checkout@v4 40 | - uses: wyvox/action-setup-pnpm@v3 41 | with: { node-version: 22 } 42 | - run: pnpm install 43 | - run: pnpm build 44 | - run: pnpm vitest ${{ matrix.testenv.args }} 45 | - run: pnpm vitest ${{ matrix.testenv.args }} 46 | working-directory: tests-public 47 | -------------------------------------------------------------------------------- /.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 | 54 | - uses: wyvox/action-setup-pnpm@v3 55 | - run: pnpm install --frozen-lockfile 56 | 57 | - name: "Generate Explanation and Prep Changelogs" 58 | id: explanation 59 | run: | 60 | set +e 61 | 62 | pnpm release-plan prepare 2> >(tee -a stderr.log >&2) 63 | 64 | 65 | if [ $? -ne 0 ]; then 66 | echo 'text<> $GITHUB_OUTPUT 67 | cat stderr.log >> $GITHUB_OUTPUT 68 | echo 'EOF' >> $GITHUB_OUTPUT 69 | else 70 | echo 'text<> $GITHUB_OUTPUT 71 | jq .description .release-plan.json -r >> $GITHUB_OUTPUT 72 | echo 'EOF' >> $GITHUB_OUTPUT 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 | node-registry-url: 'https://registry.npmjs.org' 50 | - run: pnpm install --frozen-lockfile 51 | - name: npm publish 52 | run: pnpm release-plan publish 53 | 54 | env: 55 | GITHUB_AUTH: ${{ secrets.GITHUB_TOKEN }} 56 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | declarations/ 4 | *.log 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # Allow pnpm's check of the packageManager field to 2 | # to be less strict. 3 | # 4 | # If an environment is using corepack, they'll want to set 5 | # COREPACK_ENABLE_STRICT=0 6 | package-manager-strict=false 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | .github/ 3 | node_modules/ 4 | dist/ 5 | declarations/ 6 | *.md 7 | -------------------------------------------------------------------------------- /.release-plan.json: -------------------------------------------------------------------------------- 1 | { 2 | "solution": { 3 | "signal-utils": { 4 | "impact": "patch", 5 | "oldVersion": "0.21.0", 6 | "newVersion": "0.21.1", 7 | "constraints": [ 8 | { 9 | "impact": "patch", 10 | "reason": "Appears in changelog section :bug: Bug Fix" 11 | } 12 | ], 13 | "pkgJSONPath": "./package.json" 14 | } 15 | }, 16 | "description": "## Release (2024-12-23)\n\nsignal-utils 0.21.1 (patch)\n\n#### :bug: Bug Fix\n* `signal-utils`\n * [#92](https://github.com/proposal-signals/signal-utils/pull/92) fix: Issue with `@signal`'s cache: `computed` instances sharing the same cache ([@JuerGenie](https://github.com/JuerGenie))\n\n#### Committers: 1\n- Juer.G Whang ([@JuerGenie](https://github.com/JuerGenie))\n" 17 | } 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Release (2024-12-23) 4 | 5 | signal-utils 0.21.1 (patch) 6 | 7 | #### :bug: Bug Fix 8 | * `signal-utils` 9 | * [#92](https://github.com/proposal-signals/signal-utils/pull/92) fix: Issue with `@signal`'s cache: `computed` instances sharing the same cache ([@JuerGenie](https://github.com/JuerGenie)) 10 | 11 | #### Committers: 1 12 | - Juer.G Whang ([@JuerGenie](https://github.com/JuerGenie)) 13 | 14 | ## Release (2024-12-10) 15 | 16 | signal-utils 0.21.0 (minor) 17 | 18 | #### :rocket: Enhancement 19 | * `signal-utils` 20 | * [#89](https://github.com/proposal-signals/signal-utils/pull/89) Emit TypeScript declaration maps ([@aomarks](https://github.com/aomarks)) 21 | 22 | #### :bug: Bug Fix 23 | * `signal-utils` 24 | * [#88](https://github.com/proposal-signals/signal-utils/pull/88) Bugfix: Accessing AsyncComputed status property should trigger computed function ([@aomarks](https://github.com/aomarks)) 25 | 26 | #### Committers: 1 27 | - Al Marks ([@aomarks](https://github.com/aomarks)) 28 | 29 | ## Release (2024-10-08) 30 | 31 | signal-utils 0.20.0 (minor) 32 | 33 | #### :rocket: Enhancement 34 | * `signal-utils` 35 | * [#77](https://github.com/proposal-signals/signal-utils/pull/77) Proposal: Add AsyncComputed ([@justinfagnani](https://github.com/justinfagnani)) 36 | 37 | #### Committers: 1 38 | - Justin Fagnani ([@justinfagnani](https://github.com/justinfagnani)) 39 | 40 | ## Release (2024-10-02) 41 | 42 | signal-utils 0.19.0 (minor) 43 | 44 | #### :rocket: Enhancement 45 | * `signal-utils` 46 | * [#83](https://github.com/proposal-signals/signal-utils/pull/83) Update signal-polyfill peerDependency version ([@darcyparker](https://github.com/darcyparker)) 47 | * [#76](https://github.com/proposal-signals/signal-utils/pull/76) Remove setters from signalFunction()'s State ([@justinfagnani](https://github.com/justinfagnani)) 48 | 49 | #### :memo: Documentation 50 | * `signal-utils` 51 | * [#78](https://github.com/proposal-signals/signal-utils/pull/78) Annotate deep function as a function, not a decorator ([@chee](https://github.com/chee)) 52 | 53 | #### :house: Internal 54 | * `signal-utils` 55 | * [#79](https://github.com/proposal-signals/signal-utils/pull/79) sync pnpm config for tools ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 56 | 57 | #### Committers: 4 58 | - Darcy Parker ([@darcyparker](https://github.com/darcyparker)) 59 | - Justin Fagnani ([@justinfagnani](https://github.com/justinfagnani)) 60 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 61 | - chee ([@chee](https://github.com/chee)) 62 | 63 | ## Release (2024-07-03) 64 | 65 | signal-utils 0.18.0 (minor) 66 | 67 | #### :rocket: Enhancement 68 | * `signal-utils` 69 | * [#74](https://github.com/proposal-signals/signal-utils/pull/74) Remove use of Proxy in signalFunction() ([@justinfagnani](https://github.com/justinfagnani)) 70 | 71 | #### Committers: 1 72 | - Justin Fagnani ([@justinfagnani](https://github.com/justinfagnani)) 73 | 74 | ## Release (2024-07-03) 75 | 76 | signal-utils 0.17.0 (minor) 77 | 78 | #### :rocket: Enhancement 79 | * `signal-utils` 80 | * [#73](https://github.com/proposal-signals/signal-utils/pull/73) Return dispose function from batchedEffect() ([@justinfagnani](https://github.com/justinfagnani)) 81 | * [#69](https://github.com/proposal-signals/signal-utils/pull/69) Add batchedEffect() ([@justinfagnani](https://github.com/justinfagnani)) 82 | 83 | #### :bug: Bug Fix 84 | * `signal-utils` 85 | * [#73](https://github.com/proposal-signals/signal-utils/pull/73) Return dispose function from batchedEffect() ([@justinfagnani](https://github.com/justinfagnani)) 86 | 87 | #### :memo: Documentation 88 | * `signal-utils` 89 | * [#68](https://github.com/proposal-signals/signal-utils/pull/68) Fix localCopy demo for ember ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 90 | 91 | #### :house: Internal 92 | * `signal-utils` 93 | * [#67](https://github.com/proposal-signals/signal-utils/pull/67) Update publish.yml ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 94 | 95 | #### Committers: 2 96 | - Justin Fagnani ([@justinfagnani](https://github.com/justinfagnani)) 97 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 98 | 99 | ## Release (2024-05-13) 100 | 101 | signal-utils 0.16.0 (minor) 102 | 103 | #### :rocket: Enhancement 104 | * `signal-utils` 105 | * [#61](https://github.com/proposal-signals/signal-utils/pull/61) Add reaction() utility ([@justinfagnani](https://github.com/justinfagnani)) 106 | * [#56](https://github.com/proposal-signals/signal-utils/pull/56) Implement reactive version of Array.prototype.map ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 107 | * [#53](https://github.com/proposal-signals/signal-utils/pull/53) fix(deps): mark `signal-polyfill` as a peer dependency ([@nicojs](https://github.com/nicojs)) 108 | * [#49](https://github.com/proposal-signals/signal-utils/pull/49) Unify `@signal` to work on both accessors and getters ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 109 | 110 | #### :memo: Documentation 111 | * `signal-utils` 112 | * [#66](https://github.com/proposal-signals/signal-utils/pull/66) Add purpose, and encouragement to contribute ideas ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 113 | * [#60](https://github.com/proposal-signals/signal-utils/pull/60) Add JSBin link to README ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 114 | * [#58](https://github.com/proposal-signals/signal-utils/pull/58) Update the contributing section of the README ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 115 | 116 | #### :house: Internal 117 | * `signal-utils` 118 | * [#63](https://github.com/proposal-signals/signal-utils/pull/63) Make release automation workflows more resiliant to pnpm major changes ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 119 | * [#64](https://github.com/proposal-signals/signal-utils/pull/64) Add CONTRIBUTING.md ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 120 | * [#62](https://github.com/proposal-signals/signal-utils/pull/62) Add license (MIT) ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 121 | * [#50](https://github.com/proposal-signals/signal-utils/pull/50) Prepare Release ([@github-actions[bot]](https://github.com/apps/github-actions)) 122 | * [#54](https://github.com/proposal-signals/signal-utils/pull/54) Pin tooling versions, and upgrade lockfile ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 123 | * [#51](https://github.com/proposal-signals/signal-utils/pull/51) Update repo url ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 124 | 125 | #### Committers: 4 126 | - Justin Fagnani ([@justinfagnani](https://github.com/justinfagnani)) 127 | - Nico Jansen ([@nicojs](https://github.com/nicojs)) 128 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 129 | - [@github-actions[bot]](https://github.com/apps/github-actions) 130 | 131 | ## Release (2024-04-22) 132 | 133 | signal-utils 0.15.0 (minor) 134 | 135 | #### :rocket: Enhancement 136 | * `signal-utils` 137 | * [#49](https://github.com/proposal-signals/signal-utils/pull/49) Unify `@signal` to work on both accessors and getters ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 138 | 139 | #### :house: Internal 140 | * `signal-utils` 141 | * [#51](https://github.com/proposal-signals/signal-utils/pull/51) Update repo url ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 142 | 143 | #### Committers: 1 144 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 145 | 146 | ## Release (2024-04-21) 147 | 148 | signal-utils 0.14.0 (minor) 149 | 150 | #### :rocket: Enhancement 151 | * `signal-utils` 152 | * [#47](https://github.com/proposal-signals/signal-utils/pull/47) deep and `@deepSignal` ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 153 | 154 | #### :memo: Documentation 155 | * `signal-utils` 156 | * [#46](https://github.com/proposal-signals/signal-utils/pull/46) Update README.md with install instructions ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 157 | * [#44](https://github.com/proposal-signals/signal-utils/pull/44) Update README.md ([@NesCafe62](https://github.com/NesCafe62)) 158 | 159 | #### Committers: 2 160 | - [@NesCafe62](https://github.com/NesCafe62) 161 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 162 | 163 | ## Release (2024-04-12) 164 | 165 | signal-utils 0.13.0 (minor) 166 | 167 | #### :rocket: Enhancement 168 | * `signal-utils` 169 | * [#39](https://github.com/NullVoxPopuli/signal-utils/pull/39) feat(effect): allow unsubscribe ([@nicojs](https://github.com/nicojs)) 170 | 171 | #### Committers: 1 172 | - Nico Jansen ([@nicojs](https://github.com/nicojs)) 173 | 174 | ## Release (2024-04-09) 175 | 176 | signal-utils 0.12.2 (patch) 177 | 178 | #### :bug: Bug Fix 179 | * `signal-utils` 180 | * [#37](https://github.com/NullVoxPopuli/signal-utils/pull/37) Fix effect hang / OOM issue ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 181 | 182 | #### :house: Internal 183 | * `signal-utils` 184 | * [#34](https://github.com/NullVoxPopuli/signal-utils/pull/34) Test against more environments ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 185 | 186 | #### Committers: 1 187 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 188 | 189 | ## Release (2024-04-07) 190 | 191 | signal-utils 0.12.1 (patch) 192 | 193 | #### :bug: Bug Fix 194 | * `signal-utils` 195 | * [#33](https://github.com/NullVoxPopuli/signal-utils/pull/33) effect(): Fix bug where the watcher stops watching ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 196 | 197 | #### Committers: 1 198 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 199 | 200 | ## Release (2024-04-07) 201 | 202 | signal-utils 0.12.0 (minor) 203 | 204 | #### :rocket: Enhancement 205 | * `signal-utils` 206 | * [#30](https://github.com/NullVoxPopuli/signal-utils/pull/30) Implement: microtask effect ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 207 | 208 | #### Committers: 1 209 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 210 | 211 | ## Release (2024-04-07) 212 | 213 | signal-utils 0.11.0 (minor) 214 | 215 | #### :rocket: Enhancement 216 | * `signal-utils` 217 | * [#29](https://github.com/NullVoxPopuli/signal-utils/pull/29) Implement `localCopy()` for use outside of classes ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 218 | 219 | #### Committers: 1 220 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 221 | 222 | ## Release (2024-04-07) 223 | 224 | signal-utils 0.10.0 (minor) 225 | 226 | #### :rocket: Enhancement 227 | * `signal-utils` 228 | * [#26](https://github.com/NullVoxPopuli/signal-utils/pull/26) Implement `@localCopy` and `localCopy` ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 229 | 230 | #### Committers: 1 231 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 232 | 233 | ## Release (2024-04-07) 234 | 235 | signal-utils 0.9.0 (minor) 236 | 237 | #### :rocket: Enhancement 238 | * `signal-utils` 239 | * [#24](https://github.com/NullVoxPopuli/signal-utils/pull/24) Implement `@cached` ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 240 | 241 | #### Committers: 1 242 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 243 | 244 | ## Release (2024-04-06) 245 | 246 | signal-utils 0.8.0 (minor) 247 | 248 | #### :rocket: Enhancement 249 | * `signal-utils` 250 | * [#22](https://github.com/NullVoxPopuli/signal-utils/pull/22) Impelement Map, WeakMap, Set, WeakSet ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 251 | 252 | #### Committers: 1 253 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 254 | 255 | ## Release (2024-04-03) 256 | 257 | signal-utils 0.7.0 (minor) 258 | 259 | #### :rocket: Enhancement 260 | * `signal-utils` 261 | * [#19](https://github.com/NullVoxPopuli/signal-utils/pull/19) Signal-based automatic async function ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 262 | 263 | #### Committers: 1 264 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 265 | 266 | ## Release (2024-04-02) 267 | 268 | signal-utils 0.6.0 (minor) 269 | 270 | #### :rocket: Enhancement 271 | * `signal-utils` 272 | * [#17](https://github.com/NullVoxPopuli/signal-utils/pull/17) Add utilities for avoiding the new keyword ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 273 | 274 | #### Committers: 1 275 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 276 | 277 | ## Release (2024-04-02) 278 | 279 | signal-utils 0.5.0 (minor) 280 | 281 | #### :rocket: Enhancement 282 | * `signal-utils` 283 | * [#14](https://github.com/NullVoxPopuli/signal-utils/pull/14) Implement SignalAsyncData/load ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 284 | 285 | #### Committers: 1 286 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 287 | 288 | ## Release (2024-04-02) 289 | 290 | signal-utils 0.4.0 (minor) 291 | 292 | #### :rocket: Enhancement 293 | * `signal-utils` 294 | * [#12](https://github.com/NullVoxPopuli/signal-utils/pull/12) Rename to Signal* instead of Reactive* ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 295 | 296 | #### Committers: 1 297 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 298 | 299 | ## Release (2024-04-02) 300 | 301 | signal-utils 0.3.1 (patch) 302 | 303 | #### :bug: Bug Fix 304 | * `signal-utils` 305 | * [#10](https://github.com/NullVoxPopuli/signal-utils/pull/10) Fix asset generation ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 306 | 307 | #### Committers: 1 308 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 309 | 310 | ## Release (2024-04-01) 311 | 312 | signal-utils 0.3.0 (minor) 313 | 314 | #### :rocket: Enhancement 315 | * `signal-utils` 316 | * [#9](https://github.com/NullVoxPopuli/signal-utils/pull/9) New Util: SignalArray ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 317 | 318 | #### :house: Internal 319 | * `signal-utils` 320 | * [#7](https://github.com/NullVoxPopuli/signal-utils/pull/7) Prioritize easy testing, but still have public API checking ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 321 | 322 | #### Committers: 1 323 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 324 | 325 | ## Release (2024-04-01) 326 | 327 | signal-utils 0.2.0 (minor) 328 | 329 | #### :rocket: Enhancement 330 | * `signal-utils` 331 | * [#5](https://github.com/NullVoxPopuli/signal-utils/pull/5) New util: `SignalObject` ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 332 | 333 | #### Committers: 1 334 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 335 | 336 | ## Release (2024-04-01) 337 | 338 | signal-utils 0.1.0 (minor) 339 | 340 | #### :rocket: Enhancement 341 | * `signal-utils` 342 | * [#1](https://github.com/NullVoxPopuli/signal-utils/pull/1) Infra and initial feature, the `@signal` decorator ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 343 | 344 | #### :bug: Bug Fix 345 | * `signal-utils` 346 | * [#2](https://github.com/NullVoxPopuli/signal-utils/pull/2) Fix `@signal` decorator types ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 347 | 348 | #### :house: Internal 349 | * `signal-utils` 350 | * [#4](https://github.com/NullVoxPopuli/signal-utils/pull/4) More asserstions to assure that @signal is stable ([@NullVoxPopuli](https://github.com/NullVoxPopuli)) 351 | 352 | #### Committers: 1 353 | - [@NullVoxPopuli](https://github.com/NullVoxPopuli) 354 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | This project follows the [TC39 Code of Conduct](https://tc39.es/code-of-conduct/) 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Questions 2 | 3 | Questions can be asked for on the [Issue Tracker](https://github.com/proposal-signals/signal-utils/issues) for this repo. While all questions are welcome, be sure to give the README a read just in case your question is already answered. 4 | 5 | ## Reporting a Bug 6 | 7 | 1. Update to the most recent main release if possible. We may have already fixed your bug. 8 | 1. Search for similar issues. It's possible somebody has encountered this bug already. 9 | 1. Provide a demo that specifically shows the problem. This demo should be fully operational with the exception of the bug you want to demonstrate. You may provide a repo or use a JSBin forked from: https://jsbin.com/safoqap/edit?html,output 10 | . The more pared down, the better. If it is not possible to produce a demo, please make sure you provide very specific steps to reproduce the error. If we cannot reproduce it, we will close the ticket. 11 | 1. Your issue will be verified. The provided example will be tested for correctness. We'll work with you until your issue can be verified. 12 | 1. If possible, submit a Pull Request with a failing test. Better yet, take a stab at fixing the bug yourself if you can! 13 | 14 | ## Developing 15 | 16 | Have [pnpm installed](https://pnpm.io/installation). 17 | 18 | **Install dependencies** 19 | ```bash 20 | pnpm install 21 | ``` 22 | 23 | **Start tests in watch mode** 24 | 25 | ```bash 26 | pnpm vitest --watch 27 | ``` 28 | 29 | This is the primary way to work on this package. You can use relative imports in the tests to import source files to unit test whatever needs to be unit tested. 30 | 31 | **Start build in watch mode** 32 | 33 | This isn't needed unless you're preparing for release, or needing to debug how the package si built. 34 | ```bash 35 | pnpm start 36 | ``` 37 | 38 | This will start a [concurrently](https://www.npmjs.com/package/concurrently) command that runs the vite build and vitest tests in parallel. 39 | 40 | Vitest isn't being used _within_ the package, because we want to exercise the public API, generated types, etc (through package.json#exports and all that). 41 | 42 | **Preparing a Pull Request** 43 | 44 | - ensure the build succeeds: `pnpm build` 45 | - ensure that formatting has ran: `pnpm format` 46 | - ensere that tests pass: `pnpm test` 47 | 48 | And that's it! 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 NullVoxPopuli 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /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/NullVoxPopuli/signal-utils/pulls?q=is%3Apr+is%3Aopen+%22Prepare+Release%22+in%3Atitle) PR 28 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | // "presets": ["@babel/preset-typescript"], 3 | "plugins": [ 4 | ["@babel/plugin-transform-typescript", { "allowDeclareFields": true }], 5 | ["@babel/plugin-proposal-decorators", { "version": "2023-11" }] 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "signal-utils", 3 | "version": "0.21.1", 4 | "description": "Utils for use with the Signals Proposal: https://github.com/proposal-signals/proposal-signals", 5 | "keywords": [ 6 | "signals", 7 | "signal", 8 | "reactivity", 9 | "tracked", 10 | "rune", 11 | "runes", 12 | "ember", 13 | "glimmer", 14 | "svelte", 15 | "vue", 16 | "angular", 17 | "solid", 18 | "preact" 19 | ], 20 | "repository": { 21 | "type": "git", 22 | "url": "git@github.com:proposal-signals/signal-utils.git" 23 | }, 24 | "license": "MIT", 25 | "author": "NullVoxPopuli", 26 | "type": "module", 27 | "exports": { 28 | ".": { 29 | "types": "./declarations/index.d.ts", 30 | "default": "./dist/index.ts.js" 31 | }, 32 | "./*": { 33 | "types": "./declarations/*.d.ts", 34 | "default": "./dist/*.ts.js" 35 | } 36 | }, 37 | "typesVersions": { 38 | ">=4.0.0": { 39 | "*": [ 40 | "declarations/*" 41 | ] 42 | } 43 | }, 44 | "files": [ 45 | "src", 46 | "dist", 47 | "declarations" 48 | ], 49 | "scripts": { 50 | "build": "vite build", 51 | "format": "prettier --write .", 52 | "lint": "concurrently 'npm:lint:*'", 53 | "lint:types": "tsc --noEmit --emitDeclarationOnly false", 54 | "lint:prettier": "prettier --check .", 55 | "prepack": "pnpm run build", 56 | "start": "vite build --watch", 57 | "test": "vitest" 58 | }, 59 | "peerDependencies": { 60 | "signal-polyfill": "^0.2.0" 61 | }, 62 | "devDependencies": { 63 | "@babel/core": "^7.24.4", 64 | "@babel/plugin-proposal-decorators": "^7.24.1", 65 | "@babel/plugin-syntax-decorators": "^7.24.1", 66 | "@babel/plugin-transform-typescript": "^7.24.4", 67 | "@babel/preset-typescript": "^7.24.1", 68 | "@rollup/plugin-babel": "^6.0.4", 69 | "@tsconfig/strictest": "^2.0.5", 70 | "@vitest/browser": "^1.4.0", 71 | "concurrently": "^8.2.2", 72 | "expect-type": "^0.19.0", 73 | "globby": "^14.0.1", 74 | "prettier": "^3.2.5", 75 | "release-plan": "^0.9.0", 76 | "typescript": "^5.4.3", 77 | "vite": "^5.2.8", 78 | "vite-plugin-dts": "^3.8.1", 79 | "vitest": "^1.4.0" 80 | }, 81 | "packageManager": "pnpm@9.0.6", 82 | "volta": { 83 | "node": "22.0.0", 84 | "pnpm": "9.0.6" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "." 3 | - "tests-public" 4 | -------------------------------------------------------------------------------- /src/-private/util.ts: -------------------------------------------------------------------------------- 1 | import { Signal } from "signal-polyfill"; 2 | 3 | /** 4 | * equality check here is always false so that we can dirty the storage 5 | * via setting to _anything_ 6 | * 7 | * 8 | * This is for a pattern where we don't *directly* use signals to back the values used in collections 9 | * so that instanceof checks and getters and other native features "just work" without having 10 | * to do nested proxying. 11 | * 12 | * (though, see deep.ts for nested / deep behavior) 13 | */ 14 | export const createStorage = (initial = null) => 15 | new Signal.State(initial, { equals: () => false }); 16 | 17 | /** 18 | * Just an alias for brevity 19 | */ 20 | export type Storage = Signal.State; 21 | export type StorageMap = Map; 22 | export type StorageWeakMap = WeakMap; 23 | 24 | const BOUND_FUNS = new WeakMap>(); 25 | 26 | export function fnCacheFor(context: T) { 27 | let fnCache = BOUND_FUNS.get(context); 28 | 29 | if (!fnCache) { 30 | fnCache = new Map(); 31 | BOUND_FUNS.set(context, fnCache); 32 | } 33 | 34 | return fnCache; // as Map; 35 | } 36 | -------------------------------------------------------------------------------- /src/array-map.ts: -------------------------------------------------------------------------------- 1 | import { signal } from "./index.ts"; 2 | 3 | /** 4 | * Public API of the return value of the [[map]] utility. 5 | */ 6 | export interface MappedArray { 7 | /** 8 | * Array-index access to specific mapped data. 9 | * 10 | * If the map function hasn't run yet on the source data, it will be run, and cached 11 | * for subsequent accesses. 12 | * 13 | * ```js 14 | * class Foo { 15 | * myMappedData = map({ 16 | * data: () => [1, 2, 3], 17 | * map: (num) => `hi, ${num}!` 18 | * }); 19 | * 20 | * get first() { 21 | * return this.myMappedData[0]; 22 | * } 23 | * } 24 | * ``` 25 | */ 26 | [index: number]: MappedTo; 27 | 28 | /** 29 | * Evaluate and return an array of all mapped items. 30 | * 31 | * This is useful when you need to do other Array-like operations 32 | * on the mapped data, such as filter, or find. 33 | * 34 | * ```js 35 | * class Foo { 36 | * myMappedData = map({ 37 | * data: () => [1, 2, 3], 38 | * map: (num) => `hi, ${num}!` 39 | * }); 40 | * 41 | * get everything() { 42 | * return this.myMappedData.values(); 43 | * } 44 | * } 45 | * ``` 46 | */ 47 | values: () => { [K in keyof Elements]: MappedTo }; 48 | 49 | /** 50 | * Without evaluating the map function on each element, 51 | * provide the total number of elements. 52 | * 53 | * ```js 54 | * class Foo { 55 | * myMappedData = map({ 56 | * data: () => [1, 2, 3], 57 | * map: (num) => `hi, ${num}!` 58 | * }); 59 | * 60 | * get numItems() { 61 | * return this.myMappedData.length; 62 | * } 63 | * } 64 | * ``` 65 | */ 66 | get length(): number; 67 | 68 | /** 69 | * Iterate over the mapped array, lazily invoking the passed map function 70 | * that was passed to [[map]]. 71 | * 72 | * This will always return previously mapped records without re-evaluating 73 | * the map function, so the default `{{#each}}` behavior in ember will 74 | * be optimized on "object-identity". e.g.: 75 | * 76 | * ```js 77 | * // ... 78 | * myMappedData = map({ 79 | * data: () => [1, 2, 3], 80 | * map: (num) => `hi, ${num}!` 81 | * }); 82 | * // ... 83 | * ``` 84 | * ```hbs 85 | * {{#each this.myMappedData as |datum|}} 86 | * loop body only invoked for changed entries 87 | * {{datum}} 88 | * {{/each}} 89 | * ``` 90 | * 91 | * Iteration in javascript is also provided by this iterator 92 | * ```js 93 | * class Foo { 94 | * myMappedData = map(this, { 95 | * data: () => [1, 2, 3], 96 | * map: (num) => `hi, ${num}!` 97 | * }); 98 | * 99 | * get mapAgain() { 100 | * let results = []; 101 | * 102 | * for (let datum of this.myMappedData) { 103 | * results.push(datum); 104 | * } 105 | * 106 | * return datum; 107 | * } 108 | * } 109 | * ``` 110 | */ 111 | [Symbol.iterator](): Iterator; 112 | } 113 | 114 | /** 115 | * Reactivily apply a `map` function to each element in an array, 116 | * persisting map-results for each object, based on identity. 117 | * 118 | * This is useful when you have a large collection of items that 119 | * need to be transformed into a different shape (adding/removing/modifying data/properties) 120 | * and you want the transform to be efficient when iterating over that data. 121 | * 122 | * A common use case where this `map` utility provides benefits over is 123 | * ```js 124 | * class MyClass {\ 125 | * @signal 126 | * get wrappedRecords() { 127 | * return this.records.map(record => new SomeWrapper(record)); 128 | * } 129 | * } 130 | * ``` 131 | * 132 | * Even though the above is a cached computed (via `@signal`), if any signal data accessed during the evaluation of `wrappedRecords` 133 | * changes, the entire array.map will re-run, often doing duplicate work for every unchanged item in the array. 134 | * 135 | * @return {MappedArray} an object that behaves like an array. This shouldn't be modified directly. Instead, you can freely modify the data returned by the `data` function, which should be auto-tracked in order to benefit from this abstraction. 136 | * 137 | * @example 138 | * 139 | * ```js 140 | * import { arrayMap } from 'signal-utils/array-map'; 141 | * 142 | * class MyClass { 143 | * wrappedRecords = map({ 144 | * data: () => this.records, 145 | * map: (record) => new SomeWrapper(record), 146 | * }), 147 | * } 148 | * ``` 149 | */ 150 | export function arrayMap< 151 | Elements extends readonly unknown[], 152 | MapTo = unknown, 153 | >(options: { 154 | /** 155 | * Array of non-primitives to map over 156 | * 157 | * This can be class instances, plain objects, or anything supported by WeakMap's key 158 | */ 159 | data: () => Elements; 160 | /** 161 | * Transform each element from `data`, reactively equivalent to `Array.map`. 162 | * 163 | * This function will be called only when needed / on-demand / lazily. 164 | * - if iterating over part of the data, map will only be called for the elements observed 165 | * - if not iterating, map will only be called for the elements observed. 166 | */ 167 | map: (element: Elements[0]) => MapTo; 168 | }): MappedArray { 169 | let { data, map } = options; 170 | 171 | return new TrackedArrayMap(data, map) as MappedArray; 172 | } 173 | 174 | const AT = Symbol("__AT__"); 175 | 176 | /** 177 | * @private 178 | */ 179 | export class TrackedArrayMap 180 | implements MappedArray 181 | { 182 | // Tells TS that we can array-index-access 183 | [index: number]: MappedTo; 184 | 185 | // these can't be real private fields 186 | // until @cached is a real decorator 187 | private _mapCache = new WeakMap(); 188 | private _dataFn: () => readonly Element[]; 189 | private _mapFn: (element: Element) => MappedTo; 190 | 191 | constructor( 192 | data: () => readonly Element[], 193 | map: (element: Element) => MappedTo, 194 | ) { 195 | this._dataFn = data; 196 | this._mapFn = map; 197 | 198 | // eslint-disable-next-line @typescript-eslint/no-this-alias 199 | const self = this; 200 | 201 | /** 202 | * This is what allows square-bracket index-access to work. 203 | * 204 | * Unfortunately this means the returned value is 205 | * Proxy -> Proxy -> wrapper object -> *then* the class instance 206 | * 207 | * Maybe JS has a way to implement array-index access, but I don't know how 208 | */ 209 | return new Proxy(this, { 210 | get(_target, property) { 211 | if (typeof property === "string") { 212 | let parsed = parseInt(property, 10); 213 | 214 | if (!isNaN(parsed)) { 215 | return self[AT](parsed); 216 | } 217 | } 218 | 219 | return self[property as keyof MappedArray]; 220 | }, 221 | // Is there a way to do this without lying to TypeScript? 222 | }) as TrackedArrayMap; 223 | } 224 | 225 | @signal 226 | get _records(): (Element & object)[] { 227 | let data = this._dataFn(); 228 | 229 | if (!data.every((datum) => typeof datum === "object")) { 230 | throw new Error( 231 | `Every entry in the data passed to \`map\` must be an object.`, 232 | ); 233 | } 234 | 235 | return data as Array; 236 | } 237 | 238 | values = () => [...this]; 239 | 240 | get length() { 241 | return this._records.length; 242 | } 243 | 244 | [Symbol.iterator](): Iterator { 245 | let i = 0; 246 | 247 | return { 248 | next: () => { 249 | if (i >= this.length) { 250 | return { done: true, value: null }; 251 | } 252 | 253 | let value = this[AT](i); 254 | 255 | i++; 256 | 257 | return { 258 | value, 259 | done: false, 260 | }; 261 | }, 262 | }; 263 | } 264 | 265 | /** 266 | * @private 267 | * 268 | * don't conflict with 269 | * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/at 270 | */ 271 | [AT] = (i: number) => { 272 | let record = this._records[i]; 273 | 274 | if (!record) { 275 | throw new Error( 276 | `Expected record to exist at index ${i}, but it did not. ` + 277 | `The array item is expected to exist, because the map utility resource lazily iterates along the indices of the original array passed as data. ` + 278 | `This error could happen if the data array passed to map has been mutated while iterating. ` + 279 | `To resolve this error, do not mutate arrays while iteration occurs.`, 280 | ); 281 | } 282 | 283 | let value = this._mapCache.get(record); 284 | 285 | if (!value) { 286 | value = this._mapFn(record); 287 | this._mapCache.set(record, value); 288 | } 289 | 290 | return value; 291 | }; 292 | } 293 | -------------------------------------------------------------------------------- /src/array.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | // Unfortunately, TypeScript's ability to do inference *or* type-checking in a 3 | // `Proxy`'s body is very limited, so we have to use a number of casts `as any` 4 | // to make the internal accesses work. The type safety of these is guaranteed at 5 | // the *call site* instead of within the body: you cannot do `Array.blah` in TS, 6 | // and it will blow up in JS in exactly the same way, so it is safe to assume 7 | // that properties within the getter have the correct type in TS. 8 | 9 | import { Signal } from "signal-polyfill"; 10 | import { createStorage } from "./-private/util.ts"; 11 | 12 | const ARRAY_GETTER_METHODS = new Set([ 13 | Symbol.iterator, 14 | "concat", 15 | "entries", 16 | "every", 17 | "filter", 18 | "find", 19 | "findIndex", 20 | "flat", 21 | "flatMap", 22 | "forEach", 23 | "includes", 24 | "indexOf", 25 | "join", 26 | "keys", 27 | "lastIndexOf", 28 | "map", 29 | "reduce", 30 | "reduceRight", 31 | "slice", 32 | "some", 33 | "values", 34 | ]); 35 | 36 | // For these methods, `Array` itself immediately gets the `.length` to return 37 | // after invoking them. 38 | const ARRAY_WRITE_THEN_READ_METHODS = new Set([ 39 | "fill", 40 | "push", 41 | "unshift", 42 | ]); 43 | 44 | function convertToInt(prop: number | string | symbol): number | null { 45 | if (typeof prop === "symbol") return null; 46 | 47 | const num = Number(prop); 48 | 49 | if (isNaN(num)) return null; 50 | 51 | return num % 1 === 0 ? num : null; 52 | } 53 | 54 | // This rule is correct in the general case, but it doesn't understand 55 | // declaration merging, which is how we're using the interface here. This says 56 | // `SignalArray` acts just like `Array`, but also has the properties 57 | // declared via the `class` declaration above -- but without the cost of a 58 | // subclass, which is much slower than the proxied array behavior. That is: a 59 | // `SignalArray` *is* an `Array`, just with a proxy in front of accessors and 60 | // setters, rather than a subclass of an `Array` which would be de-optimized by 61 | // the browsers. 62 | // 63 | export interface SignalArray extends Array {} 64 | 65 | export class SignalArray { 66 | /** 67 | * Creates an array from an iterable object. 68 | * @param iterable An iterable object to convert to an array. 69 | */ 70 | static from(iterable: Iterable | ArrayLike): SignalArray; 71 | 72 | /** 73 | * Creates an array from an iterable object. 74 | * @param iterable An iterable object to convert to an array. 75 | * @param mapfn A mapping function to call on every element of the array. 76 | * @param thisArg Value of 'this' used to invoke the mapfn. 77 | */ 78 | static from( 79 | iterable: Iterable | ArrayLike, 80 | mapfn: (v: T, k: number) => U, 81 | thisArg?: unknown, 82 | ): SignalArray; 83 | 84 | static from( 85 | iterable: Iterable | ArrayLike, 86 | mapfn?: (v: T, k: number) => U, 87 | thisArg?: unknown, 88 | ): SignalArray | SignalArray { 89 | return mapfn 90 | ? new SignalArray(Array.from(iterable, mapfn, thisArg)) 91 | : new SignalArray(Array.from(iterable)); 92 | } 93 | 94 | static of(...arr: T[]): SignalArray { 95 | return new SignalArray(arr); 96 | } 97 | 98 | constructor(arr: T[] = []) { 99 | let clone = arr.slice(); 100 | // eslint-disable-next-line @typescript-eslint/no-this-alias 101 | let self = this; 102 | 103 | let boundFns = new Map any>(); 104 | 105 | /** 106 | Flag to track whether we have *just* intercepted a call to `.push()` or 107 | `.unshift()`, since in those cases (and only those cases!) the `Array` 108 | itself checks `.length` to return from the function call. 109 | */ 110 | let nativelyAccessingLengthFromPushOrUnshift = false; 111 | 112 | return new Proxy(clone, { 113 | get(target, prop /*, _receiver */) { 114 | let index = convertToInt(prop); 115 | 116 | if (index !== null) { 117 | self.#readStorageFor(index); 118 | self.#collection.get(); 119 | 120 | return target[index]; 121 | } 122 | 123 | if (prop === "length") { 124 | // If we are reading `.length`, it may be a normal user-triggered 125 | // read, or it may be a read triggered by Array itself. In the latter 126 | // case, it is because we have just done `.push()` or `.unshift()`; in 127 | // that case it is safe not to mark this as a *read* operation, since 128 | // calling `.push()` or `.unshift()` cannot otherwise be part of a 129 | // "read" operation safely, and if done during an *existing* read 130 | // (e.g. if the user has already checked `.length` *prior* to this), 131 | // that will still trigger the mutation-after-consumption assertion. 132 | if (nativelyAccessingLengthFromPushOrUnshift) { 133 | nativelyAccessingLengthFromPushOrUnshift = false; 134 | } else { 135 | self.#collection.get(); 136 | } 137 | 138 | return target[prop]; 139 | } 140 | 141 | // Here, track that we are doing a `.push()` or `.unshift()` by setting 142 | // the flag to `true` so that when the `.length` is read by `Array` (see 143 | // immediately above), it knows not to dirty the collection. 144 | if (ARRAY_WRITE_THEN_READ_METHODS.has(prop)) { 145 | nativelyAccessingLengthFromPushOrUnshift = true; 146 | } 147 | 148 | if (ARRAY_GETTER_METHODS.has(prop)) { 149 | let fn = boundFns.get(prop); 150 | 151 | if (fn === undefined) { 152 | fn = (...args) => { 153 | self.#collection.get(); 154 | return (target as any)[prop](...args); 155 | }; 156 | 157 | boundFns.set(prop, fn); 158 | } 159 | 160 | return fn; 161 | } 162 | 163 | return (target as any)[prop]; 164 | }, 165 | 166 | set(target, prop, value /*, _receiver */) { 167 | (target as any)[prop] = value; 168 | 169 | let index = convertToInt(prop); 170 | 171 | if (index !== null) { 172 | self.#dirtyStorageFor(index); 173 | self.#collection.set(null); 174 | } else if (prop === "length") { 175 | self.#collection.set(null); 176 | } 177 | 178 | return true; 179 | }, 180 | 181 | getPrototypeOf() { 182 | return SignalArray.prototype; 183 | }, 184 | }) as SignalArray; 185 | } 186 | 187 | #collection = createStorage(); 188 | 189 | #storages = new Map>(); 190 | 191 | #readStorageFor(index: number) { 192 | let storage = this.#storages.get(index); 193 | 194 | if (storage === undefined) { 195 | storage = createStorage(); 196 | this.#storages.set(index, storage); 197 | } 198 | 199 | storage.get(); 200 | } 201 | 202 | #dirtyStorageFor(index: number): void { 203 | const storage = this.#storages.get(index); 204 | 205 | if (storage) { 206 | storage.set(null); 207 | } 208 | } 209 | } 210 | 211 | // Ensure instanceof works correctly 212 | Object.setPrototypeOf(SignalArray.prototype, Array.prototype); 213 | 214 | export function signalArray(x?: Item[]) { 215 | return new SignalArray(x); 216 | } 217 | -------------------------------------------------------------------------------- /src/async-computed.ts: -------------------------------------------------------------------------------- 1 | import { Signal } from "signal-polyfill"; 2 | 3 | export type AsyncComputedStatus = "initial" | "pending" | "complete" | "error"; 4 | 5 | export interface AsyncComputedOptions { 6 | /** 7 | * The initial value of the AsyncComputed. 8 | */ 9 | initialValue?: T; 10 | } 11 | 12 | /** 13 | * A signal-like object that represents an asynchronous computation. 14 | * 15 | * AsyncComputed takes a compute function that performs an asynchronous 16 | * computation and runs it inside a computed signal, while tracking the status 17 | * of the computation, including its most recent completion value and error. 18 | * 19 | * Compute functions are run when the `value`, `error`, or `complete` properties 20 | * are read, or when `get()` or `run()` are called, and are re-run when any 21 | * signals that they read change. 22 | * 23 | * If a new run of the compute function is started before the previous run has 24 | * completed, the previous run will have its AbortSignal aborted, and the result 25 | * of the previous run will be ignored. 26 | */ 27 | export class AsyncComputed { 28 | // Whether we have been notified of a pending update from the watcher. This is 29 | // set synchronously when any dependencies of the compute function change. 30 | #isNotified = false; 31 | #status = new Signal.State("initial"); 32 | 33 | /** 34 | * The current status of the AsyncComputed, which is one of 'initial', 35 | * 'pending', 'complete', or 'error'. 36 | * 37 | * The status will be 'initial' until the compute function is first run. 38 | * 39 | * The status will be 'pending' while the compute function is running. If the 40 | * status is 'pending', the `value` and `error` properties will be the result 41 | * of the previous run of the compute function. 42 | * 43 | * The status will be 'complete' when the compute function has completed 44 | * successfully. If the status is 'complete', the `value` property will be the 45 | * result of the previous run of the compute function and the `error` property 46 | * will be `undefined`. 47 | * 48 | * The status will be 'error' when the compute function has completed with an 49 | * error. If the status is 'error', the `error` property will be the error 50 | * that was thrown by the previous run of the compute function and the `value` 51 | * property will be `undefined`. 52 | * 53 | * This value is read from a signal, so any signals that read it will be 54 | * tracked as dependents of it. 55 | * 56 | * Accessing this property will cause the compute function to run if it hasn't 57 | * already. 58 | */ 59 | get status() { 60 | this.run(); 61 | // Unconditionally read the status signal to ensure that any signals that 62 | // read it are tracked as dependents. 63 | const currentState = this.#status.get(); 64 | // Read from the non-signal #isNotified field, which can be set by the 65 | // watcher synchronously. 66 | return this.#isNotified ? "pending" : currentState; 67 | } 68 | 69 | #value: Signal.State; 70 | 71 | /** 72 | * The last value that the compute function resolved with, or `undefined` if 73 | * the last run of the compute function threw an error. If the compute 74 | * function has not yet been run `value` will be the value of the 75 | * `initialValue` or `undefined`. 76 | * 77 | * This value is read from a signal, so any signals that read it will be 78 | * tracked as dependents of it. 79 | * 80 | * Accessing this property will cause the compute function to run if it hasn't 81 | * already. 82 | */ 83 | get value() { 84 | this.run(); 85 | return this.#value.get(); 86 | } 87 | 88 | #error = new Signal.State(undefined); 89 | 90 | /** 91 | * The last error that the compute function threw, or `undefined` if the last 92 | * run of the compute function resolved successfully, or if the compute 93 | * function has not yet been run. 94 | * 95 | * This value is read from a signal, so any signals that read it will be 96 | * tracked as dependents of it. 97 | * 98 | * Accessing this property will cause the compute function to run if it hasn't 99 | * already. 100 | */ 101 | get error() { 102 | this.run(); 103 | return this.#error.get(); 104 | } 105 | 106 | #deferred = new Signal.State | undefined>(undefined); 107 | 108 | /** 109 | * A promise that resolves when the compute function has completed, or rejects 110 | * if the compute function throws an error. 111 | * 112 | * If a new run of the compute function is started before the previous run has 113 | * completed, the promise will resolve with the result of the new run. 114 | * 115 | * This value is read from a signal, so any signals that read it will be 116 | * tracked as dependents of it. The identity of the promise will change if the 117 | * compute function is re-run after having completed or errored. 118 | * 119 | * Accessing this property will cause the compute function to run if it hasn't 120 | * already. 121 | */ 122 | get complete(): Promise { 123 | this.run(); 124 | // run() will have created a new deferred if needed. 125 | return this.#deferred.get()!.promise; 126 | } 127 | 128 | #computed: Signal.Computed; 129 | 130 | #watcher: Signal.subtle.Watcher; 131 | 132 | // A unique ID for the current run. This is used to ensure that runs that have 133 | // been preempted by a new run do not update state or resolve the deferred 134 | // with the wrong result. 135 | #currentRunId = 0; 136 | 137 | #currentAbortController?: AbortController; 138 | 139 | /** 140 | * Creates a new AsyncComputed signal. 141 | * 142 | * @param fn The function that performs the asynchronous computation. Any 143 | * signals read synchronously - that is, before the first await - will be 144 | * tracked as dependencies of the AsyncComputed, and cause the function to 145 | * re-run when they change. 146 | * 147 | * @param options.initialValue The initial value of the AsyncComputed. 148 | */ 149 | constructor( 150 | fn: (abortSignal: AbortSignal) => Promise, 151 | options?: AsyncComputedOptions, 152 | ) { 153 | this.#value = new Signal.State(options?.initialValue); 154 | this.#computed = new Signal.Computed(() => { 155 | const runId = ++this.#currentRunId; 156 | // Untrack reading the status signal to avoid triggering the computed when 157 | // the status changes. 158 | const status = Signal.subtle.untrack(() => this.#status.get()); 159 | 160 | // If we're not already pending, create a new deferred to track the 161 | // completion of the run. 162 | if (status !== "pending") { 163 | this.#deferred.set(Promise.withResolvers()); 164 | } 165 | this.#isNotified = false; 166 | this.#status.set("pending"); 167 | 168 | this.#currentAbortController?.abort(); 169 | this.#currentAbortController = new AbortController(); 170 | 171 | fn(this.#currentAbortController.signal).then( 172 | (result) => { 173 | // If we've been preempted by a new run, don't update the status or 174 | // resolve the deferred. 175 | if (runId !== this.#currentRunId) { 176 | return; 177 | } 178 | this.#status.set("complete"); 179 | this.#value.set(result); 180 | this.#error.set(undefined); 181 | this.#deferred.get()!.resolve(result); 182 | }, 183 | (error) => { 184 | // If we've been preempted by a new run, don't update the status or 185 | // resolve the deferred. 186 | if (runId !== this.#currentRunId) { 187 | return; 188 | } 189 | this.#status.set("error"); 190 | this.#error.set(error); 191 | this.#value.set(undefined); 192 | this.#deferred.get()!.reject(error); 193 | }, 194 | ); 195 | }); 196 | this.#watcher = new Signal.subtle.Watcher(async () => { 197 | // Set the #isNotified flag synchronously when any dependencies change, so 198 | // that it can be read synchronously by the status getter. 199 | this.#isNotified = true; 200 | this.#watcher.watch(); 201 | }); 202 | this.#watcher.watch(this.#computed); 203 | } 204 | 205 | /** 206 | * Returns the last value that the compute function resolved with, or 207 | * the initial value if the compute function has not yet been run. 208 | * 209 | * @throws The last error that the compute function threw, is the last run of 210 | * the compute function threw an error. 211 | */ 212 | get() { 213 | const status = this.status; 214 | if ( 215 | status === "error" || 216 | (status === "pending" && this.error !== undefined) 217 | ) { 218 | throw this.error; 219 | } 220 | return this.value; 221 | } 222 | 223 | /** 224 | * Runs the compute function if it is not already running and its dependencies 225 | * have changed. 226 | */ 227 | run() { 228 | this.#computed.get(); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/async-data.ts: -------------------------------------------------------------------------------- 1 | import { Signal } from "signal-polyfill"; 2 | 3 | /** A very cheap representation of the of a promise. */ 4 | type StateRepr = 5 | | [tag: "PENDING"] 6 | | [tag: "RESOLVED", value: T] 7 | | [tag: "REJECTED", error: unknown]; 8 | 9 | // We only need a single instance of the pending state in our system, since it 10 | // is otherwise unparameterized (unlike the resolved and rejected states). 11 | const PENDING = ["PENDING"] as [tag: "PENDING"]; 12 | 13 | // NOTE: this class is the implementation behind the types; the public types 14 | // layer on additional safety. See below! Additionally, the docs for the class 15 | // itself are applied to the export, not to the class, so that they will appear 16 | // when users refer to *that*. 17 | export class SignalAsyncData { 18 | /** 19 | The internal state management for the promise. 20 | 21 | - `readonly` so it cannot be mutated by the class itself after instantiation 22 | - uses true native privacy so it cannot even be read (and therefore *cannot* 23 | be depended upon) by consumers. 24 | */ 25 | readonly #state = new Signal.State>(PENDING); 26 | readonly #promise: T | Promise; 27 | 28 | /** 29 | @param promise The promise to inspect. 30 | */ 31 | constructor(data: T | Promise) { 32 | // SAFETY: do not let TS type-narrow this condition, 33 | // else, `this` is of type `never` 34 | if ((this.constructor as any) !== SignalAsyncData) { 35 | throw new Error("tracked-async-data cannot be subclassed"); 36 | } 37 | 38 | if (!isPromiseLike(data)) { 39 | this.#state.set(["RESOLVED", data]); 40 | this.#promise = Promise.resolve(data); 41 | return; 42 | } 43 | 44 | this.#promise = data; 45 | 46 | // Otherwise, we know that haven't yet handled that promise anywhere in the 47 | // system, so we continue creating a new instance. 48 | this.#promise.then( 49 | (value) => { 50 | this.#state.set(["RESOLVED", value]); 51 | }, 52 | (error) => { 53 | this.#state.set(["REJECTED", error]); 54 | }, 55 | ); 56 | } 57 | 58 | then = ( 59 | onResolved: (value: T) => null | undefined, 60 | onRejected?: (reason: unknown) => void, 61 | ) => { 62 | if (isPromiseLike(this.#promise)) { 63 | return this.#promise.then(onResolved).catch(onRejected); 64 | } 65 | 66 | if (this.state === "RESOLVED") { 67 | return onResolved(this.value as T); 68 | } 69 | 70 | if (this.state === "REJECTED" && onRejected) { 71 | return onRejected(this.error); 72 | } 73 | 74 | throw new Error(`Value was not resolveable`); 75 | }; 76 | 77 | /** 78 | * The resolution state of the promise. 79 | */ 80 | get state(): StateRepr[0] { 81 | return this.#state.get()[0]; 82 | } 83 | 84 | /** 85 | The value of the resolved promise. 86 | 87 | @note It is only valid to access `error` when `.isError` is true, that is, 88 | when `TrackedAsyncData.state` is `"ERROR"`. 89 | @warning You should not rely on this returning `T | null`! In a future 90 | breaking change which drops support for pre-Octane idioms, it will *only* 91 | return `T` and will *throw* if you access it when the state is wrong. 92 | */ 93 | get value(): T | null { 94 | let data = this.#state.get(); 95 | return data[0] === "RESOLVED" ? data[1] : null; 96 | } 97 | 98 | /** 99 | The error of the rejected promise. 100 | 101 | @note It is only valid to access `error` when `.isError` is true, that is, 102 | when `TrackedAsyncData.state` is `"ERROR"`. 103 | @warning You should not rely on this returning `null` when the state is not 104 | `"ERROR"`! In a future breaking change which drops support for pre-Octane 105 | idioms, it will *only* return `E` and will *throw* if you access it when 106 | the state is wrong. 107 | */ 108 | get error(): unknown { 109 | let data = this.#state.get(); 110 | return data[0] === "REJECTED" ? data[1] : null; 111 | } 112 | 113 | /** 114 | Is the state `"PENDING"`. 115 | */ 116 | get isPending(): boolean { 117 | return this.state === "PENDING"; 118 | } 119 | 120 | /** Is the state `"RESOLVED"`? */ 121 | get isResolved(): boolean { 122 | return this.state === "RESOLVED"; 123 | } 124 | 125 | /** Is the state `"REJECTED"`? */ 126 | get isRejected(): boolean { 127 | return this.state === "REJECTED"; 128 | } 129 | 130 | // SAFETY: casts are safe because we uphold these invariants elsewhere in the 131 | // class. It would be great if we could guarantee them statically, but getters 132 | // do not return information about the state of the class well. 133 | toJSON(): JSONRepr { 134 | const { isPending, isResolved, isRejected } = this; 135 | if (isPending) { 136 | return { isPending, isResolved, isRejected } as JSONRepr; 137 | } else if (isResolved) { 138 | return { 139 | isPending, 140 | isResolved, 141 | value: this.value, 142 | isRejected, 143 | } as JSONRepr; 144 | } else { 145 | return { 146 | isPending, 147 | isResolved, 148 | isRejected, 149 | error: this.error, 150 | } as JSONRepr; 151 | } 152 | } 153 | 154 | toString(): string { 155 | return JSON.stringify(this.toJSON(), null, 2); 156 | } 157 | } 158 | 159 | /** 160 | The JSON representation of a `TrackedAsyncData`, useful for e.g. logging. 161 | 162 | Note that you cannot reconstruct a `TrackedAsyncData` *from* this, because it 163 | is impossible to get the original promise when in a pending state! 164 | */ 165 | export type JSONRepr = 166 | | { isPending: true; isResolved: false; isRejected: false } 167 | | { isPending: false; isResolved: true; isRejected: false; value: T } 168 | | { isPending: false; isResolved: false; isRejected: true; error: unknown }; 169 | 170 | // The exported type is the intersection of three narrowed interfaces. Doing it 171 | // this way has two nice benefits: 172 | // 173 | // 1. It allows narrowing to work. For example: 174 | // 175 | // ```ts 176 | // let data = new TrackedAsyncData(Promise.resolve("hello")); 177 | // if (data.isPending) { 178 | // data.value; // null 179 | // data.error; // null 180 | // } else if (data.isPending) { 181 | // data.value; // null 182 | // data.error; // null 183 | // } else if (data.isRejected) { 184 | // data.value; // null 185 | // data.error; // unknown, can now be narrowed 186 | // } 187 | // ``` 188 | // 189 | // This dramatically improves the usability of the type in type-aware 190 | // contexts (including with templates when using Glint!) 191 | // 192 | // 2. Using `interface extends` means that (a) it is guaranteed to be a subtype 193 | // of the `_TrackedAsyncData` type, (b) that the docstrings applied to the 194 | // base type still work, and (c) that the types which are *common* to the 195 | // shared implementations (i.e. `.toJSON()` and `.toString()`) are shared 196 | // automatically. 197 | 198 | /** Utility type to check whether the string `key` is a property on an object */ 199 | function has( 200 | key: K, 201 | t: T, 202 | ): t is T & Record { 203 | return key in t; 204 | } 205 | 206 | function isPromiseLike(data: unknown): data is PromiseLike { 207 | return ( 208 | typeof data === "object" && 209 | data !== null && 210 | has("then", data) && 211 | typeof data.then === "function" 212 | ); 213 | } 214 | 215 | /** 216 | Given a `Promise`, return a `TrackedAsyncData` object which exposes the state 217 | of the promise, as well as the resolved value or thrown error once the promise 218 | resolves or fails. 219 | 220 | The function and helper accept any data, so you may use it freely in contexts 221 | where you are receiving data which may or may not be a `Promise`. 222 | 223 | ## Example 224 | 225 | Given a backing class like this: 226 | 227 | ```js 228 | import Component from '@glimmer/component'; 229 | import { signal } from 'signal-utils'; 230 | import { load } from 'ember-tracked-data/helpers/load'; 231 | 232 | export default class ExtraInfo extends Component { 233 | @signal 234 | get someData() {return load(fetch('some-url', this.args.someArg)); 235 | } 236 | } 237 | ``` 238 | 239 | You can use the result in your template like this: 240 | 241 | ```hbs 242 | {{#if this.someData.isLoading}} 243 | loading... 244 | {{else if this.someData.isLoaded}} 245 | {{this.someData.value}} 246 | {{else if this.someData.isError}} 247 | Whoops! Something went wrong: {{this.someData.error}} 248 | {{/if}} 249 | ``` 250 | 251 | You can also use the helper directly in your template: 252 | 253 | ```hbs 254 | {{#let (load @somePromise) as |data|}} 255 | {{#if data.isLoading}} 256 | 257 | {{else if data.isLoaded}} 258 | 259 | {{else if data.isError}} 260 | 261 | {{/if}} 262 | {{/let}} 263 | ``` 264 | 265 | @param data The (async) data we want to operate on: a value or a `Promise` of 266 | a value. 267 | @returns An object containing the state(, value, and error). 268 | @note Prefer to use `TrackedAsyncData` directly! This function is provided 269 | simply for symmetry with the helper and backwards compatibility. 270 | */ 271 | export function load(data: T | Promise): SignalAsyncData { 272 | return new SignalAsyncData(data); 273 | } 274 | -------------------------------------------------------------------------------- /src/async-function.ts: -------------------------------------------------------------------------------- 1 | import { Signal } from "signal-polyfill"; 2 | import { SignalAsyncData } from "./async-data.ts"; 3 | 4 | /** 5 | * Any tracked data accessed in a tracked function _before_ an `await` 6 | * will "entangle" with the function -- we can call these accessed tracked 7 | * properties, the "tracked prelude". If any properties within the tracked 8 | * payload change, the function will re-run. 9 | */ 10 | export function signalFunction(fn: () => Return): State { 11 | if (arguments.length === 1) { 12 | if (typeof fn !== "function") { 13 | throw new Error("signalFunction must be called with a function passed"); 14 | } 15 | return new State(fn); 16 | } 17 | 18 | throw new Error( 19 | "Unknown arity: signalFunction must be called with 1 argument", 20 | ); 21 | } 22 | 23 | /** 24 | * State container that represents the asynchrony of a `signalFunction` 25 | */ 26 | export class State { 27 | #data = new Signal.State | null>(null); 28 | get data() { 29 | this.#computed.get(); 30 | return this.#data.get(); 31 | } 32 | 33 | #promise = new Signal.State(undefined); 34 | get promise() { 35 | this.#computed.get(); 36 | return this.#promise.get(); 37 | } 38 | 39 | /** 40 | * ember-async-data doesn't catch errors, 41 | * so we can't rely on it to protect us from "leaky errors" 42 | * during rendering. 43 | * 44 | * See also: https://github.com/qunitjs/qunit/issues/1736 45 | */ 46 | #caughtError = new Signal.State(undefined); 47 | get caughtError() { 48 | this.#computed.get(); 49 | return this.#caughtError.get(); 50 | } 51 | 52 | #fn: () => Value; 53 | 54 | #computed: Signal.Computed; 55 | 56 | constructor(fn: () => Value) { 57 | this.#fn = fn; 58 | this.#computed = new Signal.Computed(() => { 59 | this.retry(); 60 | return this; 61 | }); 62 | } 63 | 64 | get state(): "PENDING" | "RESOLVED" | "REJECTED" | "UNSTARTED" { 65 | this.#computed.get(); 66 | return this.data?.state ?? "UNSTARTED"; 67 | } 68 | 69 | /** 70 | * Initially true, and remains true 71 | * until the underlying promise resolves or rejects. 72 | */ 73 | get isPending() { 74 | this.#computed.get(); 75 | if (!this.data) return true; 76 | 77 | return this.data.isPending ?? false; 78 | } 79 | 80 | /** 81 | * Alias for `isResolved || isRejected` 82 | */ 83 | get isFinished() { 84 | this.#computed.get(); 85 | return this.isResolved || this.isRejected; 86 | } 87 | 88 | /** 89 | * Alias for `isFinished` 90 | * which is in turn an alias for `isResolved || isRejected` 91 | */ 92 | get isSettled() { 93 | this.#computed.get(); 94 | return this.isFinished; 95 | } 96 | 97 | /** 98 | * Alias for `isPending` 99 | */ 100 | get isLoading() { 101 | this.#computed.get(); 102 | return this.isPending; 103 | } 104 | 105 | /** 106 | * When true, the function passed to `signalFunction` has resolved 107 | */ 108 | get isResolved() { 109 | this.#computed.get(); 110 | return this.data?.isResolved ?? false; 111 | } 112 | 113 | /** 114 | * Alias for `isRejected` 115 | */ 116 | get isError() { 117 | this.#computed.get(); 118 | return this.isRejected; 119 | } 120 | 121 | /** 122 | * When true, the function passed to `signalFunction` has errored 123 | */ 124 | get isRejected() { 125 | this.#computed.get(); 126 | return this.data?.isRejected ?? Boolean(this.caughtError) ?? false; 127 | } 128 | 129 | /** 130 | * this.data may not exist yet. 131 | * 132 | * Additionally, prior iterations of TrackedAsyncData did 133 | * not allow the accessing of data before 134 | * .state === 'RESOLVED' (isResolved). 135 | * 136 | * From a correctness standpoint, this is perfectly reasonable, 137 | * as it forces folks to handle the states involved with async functions. 138 | * 139 | * The original version of `signalFunction` did not use TrackedAsyncData, 140 | * and did not have these strictnesses upon property access, leaving folks 141 | * to be as correct or as fast/prototype-y as they wished. 142 | * 143 | * For now, `signalFunction` will retain that flexibility. 144 | */ 145 | get value(): Awaited | null { 146 | this.#computed.get(); 147 | if (this.data?.isResolved) { 148 | // This is sort of a lie, but it ends up working out due to 149 | // how promises chain automatically when awaited 150 | return this.data.value as Awaited; 151 | } 152 | 153 | return null; 154 | } 155 | 156 | /** 157 | * When the function passed to `signalFunction` throws an error, 158 | * that error will be the value returned by this property 159 | */ 160 | get error() { 161 | this.#computed.get(); 162 | if (this.state === "UNSTARTED" && this.caughtError) { 163 | return this.caughtError; 164 | } 165 | 166 | if (this.data?.state !== "REJECTED") { 167 | return null; 168 | } 169 | 170 | if (this.caughtError) { 171 | return this.caughtError; 172 | } 173 | 174 | return this.data?.error ?? null; 175 | } 176 | 177 | /** 178 | * Will re-invoke the function passed to `signalFunction` 179 | * this will also re-set some properties on the `State` instance. 180 | * This is the same `State` instance as before, as the `State` instance 181 | * is tied to the `fn` passed to `signalFunction` 182 | * 183 | * `error` or `resolvedValue` will remain as they were previously 184 | * until this promise resolves, and then they'll be updated to the new values. 185 | */ 186 | retry = async () => { 187 | try { 188 | /** 189 | * This function has two places where it can error: 190 | * - immediately when inovking `fn` (where auto-tracking occurs) 191 | * - after an await, "eventually" 192 | */ 193 | await this.#dangerousRetry(); 194 | } catch (e) { 195 | this.#caughtError.set(e); 196 | } 197 | }; 198 | 199 | async #dangerousRetry() { 200 | // We've previously had data, but we're about to run-again. 201 | // we need to do this again so `isLoading` goes back to `true` when re-running. 202 | // NOTE: we want to do this _even_ if this.data is already null. 203 | // it's all in the same tracking frame and the important thing is that 204 | // we can't *read* data here. 205 | this.#data.set(null); 206 | 207 | // We need to invoke this before going async so that tracked properties are 208 | // consumed (entangled with) synchronously 209 | this.#promise.set(this.#fn()); 210 | 211 | // TrackedAsyncData interacts with tracked data during instantiation. 212 | // We don't want this internal state to entangle with `signalFunction` 213 | // so that *only* the tracked data in `fn` can be entangled. 214 | await Promise.resolve(); 215 | 216 | /** 217 | * Before we await to start a new request, let's clear our error. 218 | * This is detached from the tracking frame (via the above await), 219 | * se the UI can update accordingly, without causing us to refetch 220 | */ 221 | this.#caughtError.set(null); 222 | this.#data.set(new SignalAsyncData(this.promise!)); 223 | 224 | return this.promise; 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/dedupe.ts: -------------------------------------------------------------------------------- 1 | export function dedupe() { 2 | throw new Error("Not implemented"); 3 | } 4 | -------------------------------------------------------------------------------- /src/deep.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable @typescript-eslint/ban-types */ 3 | 4 | // import { Signal } from "signal-polyfill"; 5 | import { createStorage, fnCacheFor, type Storage } from "./-private/util.ts"; 6 | 7 | // TODO: see if we can utilize these existing implementations 8 | // would these require yet another proxy? 9 | // Array clones the whole array and deep object does not 10 | // what are tradeoffs? does it matter much? 11 | // import { SignalObject } from "./object.ts"; 12 | // import { SignalArray } from "./array.ts"; 13 | 14 | type PropertyList = Array; 15 | type TrackedProxy = T; 16 | 17 | const COLLECTION = Symbol("__ COLLECTION __"); 18 | 19 | type Key = number | string | symbol; 20 | 21 | const STORAGES_CACHE = new WeakMap< 22 | object | Array, 23 | // The tracked storage for an object or array. 24 | // ie: TrackedArray, TrackedObject, but all in one 25 | Map 26 | >(); 27 | 28 | function ensureStorages(context: any) { 29 | let existing = STORAGES_CACHE.get(context); 30 | 31 | if (!existing) { 32 | existing = new Map(); 33 | STORAGES_CACHE.set(context, existing); 34 | } 35 | 36 | return existing; 37 | } 38 | 39 | function storageFor(context: any, key: Key) { 40 | let storages = ensureStorages(context); 41 | 42 | return storages.get(key); 43 | } 44 | 45 | export function initStorage(context: any, key: Key, initialValue: any = null) { 46 | let storages = ensureStorages(context); 47 | 48 | let initialStorage = createStorage(initialValue); 49 | 50 | storages.set(key, initialStorage); 51 | 52 | return initialStorage.get(); 53 | } 54 | 55 | export function hasStorage(context: any, key: Key) { 56 | return Boolean(storageFor(context, key)); 57 | } 58 | 59 | export function readStorage(context: any, key: Key) { 60 | let storage = storageFor(context, key); 61 | 62 | if (storage === undefined) { 63 | return initStorage(context, key, null); 64 | } 65 | 66 | return storage.get(); 67 | } 68 | 69 | export function updateStorage(context: any, key: Key, value: any = null) { 70 | let storage = storageFor(context, key); 71 | 72 | if (!storage) { 73 | initStorage(context, key, value); 74 | 75 | return; 76 | } 77 | 78 | storage.set(value); 79 | } 80 | 81 | export function readCollection(context: any) { 82 | if (!hasStorage(context, COLLECTION)) { 83 | initStorage(context, COLLECTION, context); 84 | } 85 | 86 | return readStorage(context, COLLECTION); 87 | } 88 | 89 | export function dirtyCollection(context: any) { 90 | if (!hasStorage(context, COLLECTION)) { 91 | initStorage(context, COLLECTION, context); 92 | } 93 | 94 | return updateStorage(context, COLLECTION, context); 95 | } 96 | 97 | /** 98 | * Deeply track an Array, and all nested objects/arrays within. 99 | * 100 | * If an element / value is ever a non-object or non-array, deep-tracking will exit 101 | * 102 | */ 103 | export function deepSignal(arr: T[]): TrackedProxy; 104 | /** 105 | * Deeply track an Object, and all nested objects/arrays within. 106 | * 107 | * If an element / value is ever a non-object or non-array, deep-tracking will exit 108 | * 109 | */ 110 | export function deepSignal>( 111 | obj: T, 112 | ): TrackedProxy; 113 | /** 114 | * Deeply track an Object or Array, and all nested objects/arrays within. 115 | * 116 | * If an element / value is ever a non-object or non-array, deep-tracking will exit 117 | * 118 | */ 119 | export function deepSignal(...args: any): any; 120 | 121 | export function deepSignal(...[target, context]: any[]): unknown { 122 | if ("kind" in context) { 123 | if (context.kind === "accessor") { 124 | return deepTrackedForDescriptor(target, context); 125 | } 126 | 127 | throw new Error(`Decorators of kind ${context.kind} are not supported.`); 128 | } 129 | 130 | return deep(target); 131 | } 132 | 133 | function deepTrackedForDescriptor( 134 | target: ClassAccessorDecoratorTarget, 135 | context: ClassAccessorDecoratorContext, 136 | ): ClassAccessorDecoratorResult { 137 | const { name: key } = context; 138 | const { get } = target; 139 | 140 | return { 141 | get(): Value { 142 | if (hasStorage(this, key)) { 143 | return readStorage(this, key) as Value; 144 | } 145 | 146 | let value = get.call(this); // already deep, due to init 147 | return initStorage(this, key, value) as Value; 148 | }, 149 | 150 | set(value: Value) { 151 | let deepValue = deep(value); 152 | updateStorage(this, key, deepValue); 153 | // set.call(this, deepValue); 154 | //updateStorage(this, key, deepTracked(value)); 155 | // SAFETY: does TS not allow us to have a different type internally? 156 | // maybe I did something goofy. 157 | //(get.call(this) as Signal.State).set(value); 158 | }, 159 | 160 | init(value: Value) { 161 | return deep(value); 162 | }, 163 | }; 164 | } 165 | 166 | const TARGET = Symbol("TARGET"); 167 | const IS_PROXIED = Symbol("IS_PROXIED"); 168 | 169 | const SECRET_PROPERTIES: PropertyList = [TARGET, IS_PROXIED]; 170 | 171 | const ARRAY_COLLECTION_PROPERTIES = ["length"]; 172 | const ARRAY_CONSUME_METHODS = [ 173 | Symbol.iterator, 174 | "at", 175 | "concat", 176 | "entries", 177 | "every", 178 | "filter", 179 | "find", 180 | "findIndex", 181 | "findLast", 182 | "findLastIndex", 183 | "flat", 184 | "flatMap", 185 | "forEach", 186 | "group", 187 | "groupToMap", 188 | "includes", 189 | "indexOf", 190 | "join", 191 | "keys", 192 | "lastIndexOf", 193 | "map", 194 | "reduce", 195 | "reduceRight", 196 | "slice", 197 | "some", 198 | "toString", 199 | "values", 200 | "length", 201 | ]; 202 | 203 | const ARRAY_DIRTY_METHODS = [ 204 | "sort", 205 | "fill", 206 | "pop", 207 | "push", 208 | "shift", 209 | "splice", 210 | "unshift", 211 | "reverse", 212 | ]; 213 | 214 | const ARRAY_QUERY_METHODS: PropertyList = [ 215 | "indexOf", 216 | "contains", 217 | "lastIndexOf", 218 | "includes", 219 | ]; 220 | 221 | export function deep(obj: T | null | undefined): T { 222 | if (obj === null || obj === undefined) { 223 | return obj as T; 224 | } 225 | 226 | if (obj[IS_PROXIED as keyof T]) { 227 | return obj; 228 | } 229 | 230 | if (Array.isArray(obj)) { 231 | return deepProxy(obj, arrayProxyHandler) as unknown as T; 232 | } 233 | 234 | if (typeof obj === "object") { 235 | return deepProxy(obj, objProxyHandler) as unknown as T; 236 | } 237 | 238 | return obj; 239 | } 240 | 241 | const arrayProxyHandler: ProxyHandler> = { 242 | get(target: T, property: keyof T, receiver: T) { 243 | let value = Reflect.get(target, property, receiver); 244 | 245 | if (property === TARGET) { 246 | return value; 247 | } 248 | 249 | if (property === IS_PROXIED) { 250 | return true; 251 | } 252 | 253 | if (typeof property === "string") { 254 | let parsed = parseInt(property, 10); 255 | 256 | if (!isNaN(parsed)) { 257 | // Why consume the collection? 258 | // because indices can change if the collection changes 259 | readCollection(target); 260 | readStorage(target, parsed); 261 | 262 | return deep(value); 263 | } 264 | 265 | if (ARRAY_COLLECTION_PROPERTIES.includes(property)) { 266 | readCollection(target); 267 | 268 | return value; 269 | } 270 | } 271 | 272 | if (typeof value === "function") { 273 | let fnCache = fnCacheFor(target); 274 | let existing = fnCache.get(property as KeyType); 275 | 276 | if (!existing) { 277 | let fn = (...args: unknown[]) => { 278 | if (typeof property === "string") { 279 | if (ARRAY_QUERY_METHODS.includes(property)) { 280 | readCollection(target); 281 | 282 | let fn = target[property]; 283 | 284 | if (typeof fn === "function") { 285 | return fn.call(target, ...args.map(unwrap)); 286 | } 287 | } else if (ARRAY_CONSUME_METHODS.includes(property)) { 288 | readCollection(target); 289 | } else if (ARRAY_DIRTY_METHODS.includes(property)) { 290 | dirtyCollection(target); 291 | } 292 | } 293 | 294 | return Reflect.apply(value, receiver, args); 295 | }; 296 | 297 | fnCache.set(property as KeyType, fn); 298 | 299 | return fn; 300 | } 301 | 302 | return existing; 303 | } 304 | 305 | return value; 306 | }, 307 | set(target, property, value, receiver) { 308 | if (typeof property === "string") { 309 | let parsed = parseInt(property, 10); 310 | 311 | if (!isNaN(parsed)) { 312 | updateStorage(target, property, value); 313 | // when setting, the collection must be dirtied.. :( 314 | // this is to support updating {{#each}}, 315 | // which uses object identity by default 316 | dirtyCollection(target); 317 | 318 | return Reflect.set(target, property, value, receiver); 319 | } else if (property === "length") { 320 | dirtyCollection(target); 321 | 322 | return Reflect.set(target, property, value, receiver); 323 | } 324 | } 325 | 326 | dirtyCollection(target); 327 | 328 | return Reflect.set(target, property, value, receiver); 329 | }, 330 | has(target, property) { 331 | if (SECRET_PROPERTIES.includes(property)) { 332 | return true; 333 | } 334 | 335 | readStorage(target, property); 336 | 337 | return property in target; 338 | }, 339 | getPrototypeOf() { 340 | return Array.prototype; 341 | }, 342 | }; 343 | 344 | const objProxyHandler = { 345 | get(target: T, prop: keyof T, receiver: T) { 346 | if (prop === TARGET) { 347 | return target; 348 | } 349 | 350 | if (prop === IS_PROXIED) { 351 | return true; 352 | } 353 | 354 | readStorage(target, prop); 355 | 356 | return deep(Reflect.get(target, prop, receiver)); 357 | }, 358 | has(target: T, prop: keyof T) { 359 | if (SECRET_PROPERTIES.includes(prop)) { 360 | return true; 361 | } 362 | 363 | readStorage(target, prop); 364 | 365 | return prop in target; 366 | }, 367 | 368 | ownKeys(target: T) { 369 | readCollection(target); 370 | 371 | return Reflect.ownKeys(target); 372 | }, 373 | 374 | set( 375 | target: T, 376 | prop: keyof T, 377 | value: T[keyof T], 378 | receiver: T, 379 | ) { 380 | updateStorage(target, prop); 381 | dirtyCollection(target); 382 | 383 | return Reflect.set(target, prop, value, receiver); 384 | }, 385 | 386 | getPrototypeOf() { 387 | return Object.prototype; 388 | }, 389 | }; 390 | 391 | const PROXY_CACHE = new WeakMap(); 392 | 393 | function unwrap(obj: T) { 394 | if (typeof obj === "object" && obj && TARGET in obj) { 395 | return obj[TARGET as keyof T]; 396 | } 397 | 398 | return obj; 399 | } 400 | 401 | function deepProxy( 402 | obj: T, 403 | handler: ProxyHandler, 404 | ): TrackedProxy { 405 | let existing = PROXY_CACHE.get(obj); 406 | 407 | if (existing) { 408 | return existing as T; 409 | } 410 | 411 | let proxied = new Proxy(obj, handler); 412 | 413 | PROXY_CACHE.set(obj, proxied); 414 | 415 | return proxied as T; 416 | } 417 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Signal } from "signal-polyfill"; 2 | 3 | /** 4 | * Usage: 5 | * ```js 6 | * export class Counter { 7 | * @signal accessor #value = 0; 8 | * 9 | * get doubled() { 10 | * return this.#value * 2; 11 | * } 12 | * 13 | * increment() { 14 | * this.#value++; 15 | * } 16 | * 17 | * decrement() { 18 | * if (this.#value > 0) { 19 | * this.#value--; 20 | * } 21 | * } 22 | * } 23 | * ``` 24 | */ 25 | export function signal( 26 | ...args: Parameters> 27 | ): ReturnType>; 28 | 29 | /** 30 | * Usage: 31 | * ```js 32 | * import { signal } from 'signal-utils'; 33 | * 34 | * export class Counter { 35 | * @signal accessor #value = 0; 36 | * 37 | * @signal 38 | * get expensive() { 39 | * // some expensive operation 40 | * return this.#value * 2; 41 | * } 42 | * 43 | * increment() { 44 | * this.#value++; 45 | * } 46 | * } 47 | * ``` 48 | */ 49 | export function signal( 50 | ...args: Parameters> 51 | ): ReturnType>; 52 | 53 | export function signal( 54 | ...args: 55 | | Parameters> 56 | | Parameters> 57 | ) { 58 | if (args[1].kind === "accessor") { 59 | return stateDecorator( 60 | ...(args as Parameters>), 61 | ); 62 | } 63 | 64 | if (args[1].kind === "getter") { 65 | return computedDecorator( 66 | ...(args as Parameters>), 67 | ); 68 | } 69 | 70 | throw new Error(`@signal can only be used on accessors or getters`); 71 | } 72 | 73 | function stateDecorator( 74 | target: ClassAccessorDecoratorTarget, 75 | context: ClassAccessorDecoratorContext, 76 | ): ClassAccessorDecoratorResult { 77 | const { get } = target; 78 | 79 | if (context.kind !== "accessor") { 80 | throw new Error(`Expected to be used on an accessor property`); 81 | } 82 | 83 | return { 84 | get(): Value { 85 | // SAFETY: does TS not allow us to have a different type internally? 86 | // maybe I did something goofy. 87 | return (get.call(this) as Signal.State).get(); 88 | }, 89 | 90 | set(value: Value) { 91 | // SAFETY: does TS not allow us to have a different type internally? 92 | // maybe I did something goofy. 93 | (get.call(this) as Signal.State).set(value); 94 | }, 95 | 96 | init(value: Value) { 97 | // SAFETY: does TS not allow us to have a different type internally? 98 | // maybe I did something goofy. 99 | return new Signal.State(value) as unknown as Value; 100 | }, 101 | }; 102 | } 103 | 104 | function computedDecorator( 105 | target: () => Value, 106 | context: ClassGetterDecoratorContext, 107 | ): () => Value { 108 | const kind = context.kind; 109 | 110 | if (kind !== "getter") { 111 | throw new Error(`Can only use @cached on getters.`); 112 | } 113 | 114 | const caches = new WeakMap< 115 | typeof target, 116 | WeakMap> 117 | >(); 118 | 119 | return function (this: unknown) { 120 | let cache = caches.get(target); 121 | if (!cache) { 122 | cache = new WeakMap(); 123 | caches.set(target, cache); 124 | } 125 | let effect = cache.get(this as object); 126 | if (!effect) { 127 | effect = new Signal.Computed(() => target.call(this)); 128 | cache.set(this as object, effect); 129 | } 130 | 131 | return effect.get(); 132 | }; 133 | } 134 | -------------------------------------------------------------------------------- /src/local-copy.ts: -------------------------------------------------------------------------------- 1 | import { signal } from "./index.ts"; 2 | 3 | class Meta { 4 | prevRemote?: Value; 5 | peek?: () => Value; 6 | @signal accessor value: Value | undefined; 7 | } 8 | 9 | function getOrCreateMeta( 10 | instance: WeakKey, 11 | metas: WeakMap>, 12 | initializer?: Value | Function, 13 | ) { 14 | let meta = metas.get(instance); 15 | 16 | if (meta === undefined) { 17 | meta = new Meta(); 18 | metas.set(instance, meta); 19 | 20 | meta.value = meta.peek = 21 | typeof initializer === "function" 22 | ? initializer.call(instance) 23 | : initializer; 24 | } 25 | 26 | return meta; 27 | } 28 | 29 | function get(obj: any, path: string) { 30 | let current = obj; 31 | let parts = path.split("."); 32 | 33 | for (let part of parts) { 34 | if (!current) return current; 35 | 36 | if (!(part in current)) { 37 | throw new Error( 38 | `sub-path ${part} (from ${path}) does not exist on ${JSON.stringify(current)}.`, 39 | ); 40 | } 41 | 42 | current = current[part]; 43 | } 44 | 45 | return current; 46 | } 47 | 48 | /** 49 | * Forks remote state for local modification 50 | * 51 | * ```js 52 | * import { Signal } from 'signal-polyfill'; 53 | * import { localCopy } from 'signal-utils'; 54 | * 55 | * const remote = new Signal.State(0); 56 | * 57 | * const local = localCopy(() => remote.get()); 58 | * ``` 59 | */ 60 | export function localCopy( 61 | fn: () => Value, 62 | ): { get(): Value; set(v: Value): void }; 63 | 64 | /** 65 | * Forks remote state for local modification 66 | * 67 | * ```js 68 | * import { localCopy } from 'signal-utils'; 69 | * 70 | * class Demo { 71 | * @localCopy('remote.value') accessor localValue; 72 | * } 73 | * ``` 74 | */ 75 | export function localCopy( 76 | memo: string, 77 | initializer?: Value | (() => Value), 78 | ): ( 79 | _target: ClassAccessorDecoratorTarget, 80 | _context: ClassAccessorDecoratorContext, 81 | ) => ClassAccessorDecoratorResult; 82 | 83 | /** 84 | * Forks remote state for local modification 85 | * 86 | * ```js 87 | * import { localCopy } from 'signal-utils'; 88 | * 89 | * class Demo { 90 | * @localCopy('remote.value') accessor localValue; 91 | * } 92 | * ``` 93 | */ 94 | export function localCopy( 95 | ...args: any[] 96 | ) { 97 | if (typeof args[0] === "function") { 98 | return localCopyFn(args[0]); 99 | } 100 | 101 | let [first, second] = args; 102 | 103 | return localCopyDecorator( 104 | first, 105 | second as undefined | Value | (() => Value), 106 | ); 107 | } 108 | 109 | function localCopyFn( 110 | memoFn: () => Value, 111 | ) { 112 | let metas = new WeakMap>(); 113 | 114 | return { 115 | get(this: This): Value { 116 | let meta = getOrCreateMeta(this, metas); 117 | let { prevRemote } = meta; 118 | 119 | let incomingValue = memoFn(); 120 | 121 | if (prevRemote !== incomingValue) { 122 | // If the incoming value is not the same as the previous incoming value, 123 | // update the local value to match the new incoming value, and update 124 | // the previous incoming value. 125 | meta.value = meta.prevRemote = incomingValue; 126 | } 127 | 128 | return meta.value as Value; 129 | }, 130 | 131 | set(this: WeakKey, value: Value) { 132 | if (!metas.has(this)) { 133 | let meta = getOrCreateMeta(this, metas); 134 | meta.prevRemote = memoFn(); 135 | meta.value = value; 136 | return; 137 | } 138 | 139 | getOrCreateMeta(this, metas).value = value; 140 | }, 141 | }; 142 | } 143 | 144 | function localCopyDecorator( 145 | memo: string, 146 | initializer: undefined | Value | (() => Value), 147 | ) { 148 | if (typeof memo !== "string") { 149 | throw new Error( 150 | `@localCopy() must be given a memo path as its first argument, received \`${String( 151 | memo, 152 | )}\``, 153 | ); 154 | } 155 | 156 | let metas = new WeakMap>(); 157 | 158 | return function localCopyDecorator( 159 | _target: ClassAccessorDecoratorTarget, 160 | _context: ClassAccessorDecoratorContext, 161 | ): ClassAccessorDecoratorResult { 162 | let memoFn = (obj: any) => get(obj, memo); 163 | 164 | return { 165 | get(this: This): Value { 166 | let meta = getOrCreateMeta(this, metas, initializer); 167 | let { prevRemote } = meta; 168 | 169 | let incomingValue = memoFn(this); 170 | 171 | if (prevRemote !== incomingValue) { 172 | // If the incoming value is not the same as the previous incoming value, 173 | // update the local value to match the new incoming value, and update 174 | // the previous incoming value. 175 | meta.value = meta.prevRemote = incomingValue; 176 | } 177 | 178 | return meta.value as Value; 179 | }, 180 | 181 | set(this: WeakKey, value: Value) { 182 | if (!metas.has(this)) { 183 | let meta = getOrCreateMeta(this, metas, initializer); 184 | meta.prevRemote = memoFn(this); 185 | meta.value = value; 186 | return; 187 | } 188 | 189 | getOrCreateMeta(this, metas, initializer).value = value; 190 | }, 191 | }; 192 | }; 193 | } 194 | -------------------------------------------------------------------------------- /src/map.ts: -------------------------------------------------------------------------------- 1 | import { createStorage, type StorageMap } from "./-private/util.ts"; 2 | 3 | export class SignalMap implements Map { 4 | private collection = createStorage(); 5 | 6 | private storages: StorageMap = new Map(); 7 | 8 | private vals: Map; 9 | 10 | private readStorageFor(key: K): void { 11 | const { storages } = this; 12 | let storage = storages.get(key); 13 | 14 | if (storage === undefined) { 15 | storage = createStorage(); 16 | storages.set(key, storage); 17 | } 18 | 19 | storage.get(); 20 | } 21 | 22 | private dirtyStorageFor(key: K): void { 23 | const storage = this.storages.get(key); 24 | 25 | if (storage) { 26 | storage.set(null); 27 | } 28 | } 29 | 30 | constructor(); 31 | constructor(entries: readonly (readonly [K, V])[] | null); 32 | constructor(iterable: Iterable); 33 | constructor( 34 | existing?: 35 | | readonly (readonly [K, V])[] 36 | | Iterable 37 | | null 38 | | undefined, 39 | ) { 40 | // TypeScript doesn't correctly resolve the overloads for calling the `Map` 41 | // constructor for the no-value constructor. This resolves that. 42 | this.vals = existing ? new Map(existing) : new Map(); 43 | } 44 | 45 | // **** KEY GETTERS **** 46 | get(key: K): V | undefined { 47 | // entangle the storage for the key 48 | this.readStorageFor(key); 49 | 50 | return this.vals.get(key); 51 | } 52 | 53 | has(key: K): boolean { 54 | this.readStorageFor(key); 55 | 56 | return this.vals.has(key); 57 | } 58 | 59 | // **** ALL GETTERS **** 60 | entries(): IterableIterator<[K, V]> { 61 | this.collection.get(); 62 | 63 | return this.vals.entries(); 64 | } 65 | 66 | keys(): IterableIterator { 67 | this.collection.get(); 68 | 69 | return this.vals.keys(); 70 | } 71 | 72 | values(): IterableIterator { 73 | this.collection.get(); 74 | 75 | return this.vals.values(); 76 | } 77 | 78 | forEach(fn: (value: V, key: K, map: Map) => void): void { 79 | this.collection.get(); 80 | 81 | this.vals.forEach(fn); 82 | } 83 | 84 | get size(): number { 85 | this.collection.get(); 86 | 87 | return this.vals.size; 88 | } 89 | 90 | [Symbol.iterator](): IterableIterator<[K, V]> { 91 | this.collection.get(); 92 | 93 | return this.vals[Symbol.iterator](); 94 | } 95 | 96 | get [Symbol.toStringTag](): string { 97 | return this.vals[Symbol.toStringTag]; 98 | } 99 | 100 | // **** KEY SETTERS **** 101 | set(key: K, value: V): this { 102 | this.dirtyStorageFor(key); 103 | this.collection.set(null); 104 | 105 | this.vals.set(key, value); 106 | 107 | return this; 108 | } 109 | 110 | delete(key: K): boolean { 111 | this.dirtyStorageFor(key); 112 | this.collection.set(null); 113 | 114 | return this.vals.delete(key); 115 | } 116 | 117 | // **** ALL SETTERS **** 118 | clear(): void { 119 | this.storages.forEach((s) => s.set(null)); 120 | this.collection.set(null); 121 | 122 | this.vals.clear(); 123 | } 124 | } 125 | 126 | // So instanceof works 127 | Object.setPrototypeOf(SignalMap.prototype, Map.prototype); 128 | -------------------------------------------------------------------------------- /src/object.ts: -------------------------------------------------------------------------------- 1 | import { Signal } from "signal-polyfill"; 2 | import { createStorage } from "./-private/util.ts"; 3 | 4 | /** 5 | * Implementation based of tracked-built-ins' TrackedObject 6 | * https://github.com/tracked-tools/tracked-built-ins/blob/master/addon/src/-private/object.js 7 | */ 8 | export class SignalObjectImpl { 9 | static fromEntries( 10 | entries: Iterable, 11 | ) { 12 | return new SignalObjectImpl(Object.fromEntries(entries)) as T; 13 | } 14 | #storages = new Map>(); 15 | #collection = createStorage(); 16 | 17 | constructor(obj = {}) { 18 | let proto = Object.getPrototypeOf(obj); 19 | let descs = Object.getOwnPropertyDescriptors(obj); 20 | 21 | let clone = Object.create(proto); 22 | 23 | for (let prop in descs) { 24 | // SAFETY: we just iterated over the property, so having to do an 25 | // existence check here is a little silly 26 | Object.defineProperty(clone, prop, descs[prop]!); 27 | } 28 | 29 | let self = this; 30 | 31 | return new Proxy(clone, { 32 | get(target, prop, receiver) { 33 | // we don't use the signals directly 34 | // because we don't know (nor care!) what the value would be 35 | // and the value could be replaced 36 | // (this is also important for supporting getters) 37 | self.#readStorageFor(prop); 38 | 39 | return Reflect.get(target, prop, receiver); 40 | }, 41 | 42 | has(target, prop) { 43 | self.#readStorageFor(prop); 44 | 45 | return prop in target; 46 | }, 47 | 48 | ownKeys(target) { 49 | self.#collection.get(); 50 | 51 | return Reflect.ownKeys(target); 52 | }, 53 | 54 | set(target, prop, value, receiver) { 55 | let result = Reflect.set(target, prop, value, receiver); 56 | 57 | self.#dirtyStorageFor(prop); 58 | self.#dirtyCollection(); 59 | 60 | return result; 61 | }, 62 | 63 | deleteProperty(target, prop) { 64 | if (prop in target) { 65 | delete target[prop]; 66 | self.#dirtyStorageFor(prop); 67 | self.#dirtyCollection(); 68 | } 69 | 70 | return true; 71 | }, 72 | 73 | getPrototypeOf() { 74 | return SignalObjectImpl.prototype; 75 | }, 76 | }); 77 | } 78 | 79 | #readStorageFor(key: PropertyKey) { 80 | let storage = this.#storages.get(key); 81 | 82 | if (storage === undefined) { 83 | storage = createStorage(); 84 | this.#storages.set(key, storage); 85 | } 86 | 87 | storage.get(); 88 | } 89 | 90 | #dirtyStorageFor(key: PropertyKey) { 91 | const storage = this.#storages.get(key); 92 | 93 | if (storage) { 94 | storage.set(null); 95 | } 96 | } 97 | 98 | #dirtyCollection() { 99 | this.#collection.set(null); 100 | } 101 | } 102 | 103 | interface SignalObject { 104 | fromEntries( 105 | entries: Iterable, 106 | ): { [k: string]: T }; 107 | 108 | new = Record>( 109 | obj?: T, 110 | ): T; 111 | } 112 | // Types are too hard in proxy-implementation 113 | // we want TS to think the SignalObject is Object-like 114 | 115 | /** 116 | * Create a reactive Object, backed by Signals, using a Proxy. 117 | * This allows dynamic creation and deletion of signals using the object primitive 118 | * APIs that most folks are familiar with -- the only difference is instantiation. 119 | * ```js 120 | * const obj = new SignalObject({ foo: 123 }); 121 | * 122 | * obj.foo // 123 123 | * obj.foo = 456 124 | * obj.foo // 456 125 | * obj.bar = 2 126 | * obj.bar // 2 127 | * ``` 128 | */ 129 | export const SignalObject: SignalObject = 130 | SignalObjectImpl as unknown as SignalObject; 131 | 132 | export function signalObject>( 133 | obj?: T | undefined, 134 | ) { 135 | return new SignalObject(obj); 136 | } 137 | -------------------------------------------------------------------------------- /src/promise.ts: -------------------------------------------------------------------------------- 1 | export function promise() { 2 | throw new Error("Not implemented"); 3 | } 4 | -------------------------------------------------------------------------------- /src/set.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createStorage, 3 | type StorageMap, 4 | type Storage, 5 | } from "./-private/util.ts"; 6 | 7 | export class SignalSet implements Set { 8 | private collection = createStorage(); 9 | 10 | private storages: StorageMap = new Map(); 11 | 12 | private vals: Set; 13 | 14 | private storageFor(key: T): Storage { 15 | const storages = this.storages; 16 | let storage = storages.get(key); 17 | 18 | if (storage === undefined) { 19 | storage = createStorage(); 20 | storages.set(key, storage); 21 | } 22 | 23 | return storage; 24 | } 25 | 26 | private dirtyStorageFor(key: T): void { 27 | const storage = this.storages.get(key); 28 | 29 | if (storage) { 30 | storage.set(null); 31 | } 32 | } 33 | 34 | constructor(); 35 | constructor(values: readonly T[] | null); 36 | constructor(iterable: Iterable); 37 | constructor(existing?: readonly T[] | Iterable | null | undefined) { 38 | this.vals = new Set(existing); 39 | } 40 | 41 | // **** KEY GETTERS **** 42 | has(value: T): boolean { 43 | this.storageFor(value).get(); 44 | 45 | return this.vals.has(value); 46 | } 47 | 48 | // **** ALL GETTERS **** 49 | entries(): IterableIterator<[T, T]> { 50 | this.collection.get(); 51 | 52 | return this.vals.entries(); 53 | } 54 | 55 | keys(): IterableIterator { 56 | this.collection.get(); 57 | 58 | return this.vals.keys(); 59 | } 60 | 61 | values(): IterableIterator { 62 | this.collection.get(); 63 | 64 | return this.vals.values(); 65 | } 66 | 67 | forEach(fn: (value1: T, value2: T, set: Set) => void): void { 68 | this.collection.get(); 69 | 70 | this.vals.forEach(fn); 71 | } 72 | 73 | get size(): number { 74 | this.collection.get(); 75 | 76 | return this.vals.size; 77 | } 78 | 79 | [Symbol.iterator](): IterableIterator { 80 | this.collection.get(); 81 | 82 | return this.vals[Symbol.iterator](); 83 | } 84 | 85 | get [Symbol.toStringTag](): string { 86 | return this.vals[Symbol.toStringTag]; 87 | } 88 | 89 | // **** KEY SETTERS **** 90 | add(value: T): this { 91 | this.dirtyStorageFor(value); 92 | this.collection.set(null); 93 | 94 | this.vals.add(value); 95 | 96 | return this; 97 | } 98 | 99 | delete(value: T): boolean { 100 | this.dirtyStorageFor(value); 101 | this.collection.set(null); 102 | 103 | return this.vals.delete(value); 104 | } 105 | 106 | // **** ALL SETTERS **** 107 | clear(): void { 108 | this.storages.forEach((s) => s.set(null)); 109 | this.collection.set(null); 110 | 111 | this.vals.clear(); 112 | } 113 | } 114 | 115 | // So instanceof works 116 | Object.setPrototypeOf(SignalSet.prototype, Set.prototype); 117 | -------------------------------------------------------------------------------- /src/subtle/batched-effect.ts: -------------------------------------------------------------------------------- 1 | import { Signal } from "signal-polyfill"; 2 | 3 | const notifiedEffects = new Set<{ 4 | computed: Signal.Computed; 5 | watcher: Signal.subtle.Watcher; 6 | }>(); 7 | 8 | let batchDepth = 0; 9 | 10 | /** 11 | * Runs the given function inside of a "batch" and calls any batched effects 12 | * (those created with `batchEffect()`) that depend on updated signals 13 | * synchronously after the function completes. 14 | * 15 | * Batches can be nested, and effects will only be called once at the end of the 16 | * outermost batch. 17 | * 18 | * Batching does not change how the signal graph updates, or change any other 19 | * watcher or effect system. Accessing signals that are updated within a batch 20 | * will return their updates value. Other computations, watcher, and effects 21 | * created outside of a batch that depend on updated signals will be run as 22 | * usual. 23 | * 24 | * @param fn The function to run inside the batch. 25 | */ 26 | export const batch = (fn: () => void) => { 27 | batchDepth++; 28 | try { 29 | // Run the function to notifiy watchers 30 | fn(); 31 | } finally { 32 | batchDepth--; 33 | 34 | if (batchDepth !== 0) { 35 | return; 36 | } 37 | 38 | // Copy then clear the notified effects 39 | const effects = [...notifiedEffects]; 40 | notifiedEffects.clear(); 41 | 42 | // Run all the batched effect callbacks and re-enable the watchers 43 | let exceptions!: any[]; 44 | 45 | for (const { computed, watcher } of effects) { 46 | watcher.watch(computed); 47 | try { 48 | computed.get(); 49 | } catch (e) { 50 | (exceptions ??= []).push(e); 51 | } 52 | } 53 | 54 | if (exceptions !== undefined) { 55 | if (exceptions.length === 1) { 56 | throw exceptions![0]; 57 | } else { 58 | throw new AggregateError( 59 | exceptions!, 60 | "Multiple exceptions thrown in batched effects", 61 | ); 62 | } 63 | } 64 | } 65 | }; 66 | 67 | /** 68 | * Creates an effect that runs synchronously at the end of a `batch()` call if 69 | * any of the signals it depends on have been updated. 70 | * 71 | * The effect also runs asynchronously, on the microtask queue, if any of the 72 | * signals it depends on have been updated outside of a `batch()` call. 73 | * 74 | * @param effectFn The function to run as an effect. 75 | * @returns A function that stops and disposes the effect. 76 | */ 77 | export const batchedEffect = (effectFn: () => void) => { 78 | const computed = new Signal.Computed(effectFn); 79 | const watcher = new Signal.subtle.Watcher(async () => { 80 | // Synchonously add the effect to the notified effects 81 | notifiedEffects.add(entry); 82 | 83 | // Check if our effect is still in the notified effects 84 | await 0; 85 | 86 | if (notifiedEffects.has(entry)) { 87 | // If it is, then we call it async and remove it 88 | notifiedEffects.delete(entry); 89 | computed.get(); 90 | } 91 | }); 92 | const entry = { computed, watcher }; 93 | watcher.watch(computed); 94 | computed.get(); 95 | return () => { 96 | watcher.unwatch(computed); 97 | notifiedEffects.delete(entry); 98 | }; 99 | }; 100 | -------------------------------------------------------------------------------- /src/subtle/microtask-effect.ts: -------------------------------------------------------------------------------- 1 | import { Signal } from "signal-polyfill"; 2 | 3 | // NOTE: this implementation *LEAKS* 4 | // because there is nothing to unwatch a computed. 5 | 6 | let pending = false; 7 | 8 | let watcher = new Signal.subtle.Watcher(() => { 9 | if (!pending) { 10 | pending = true; 11 | queueMicrotask(() => { 12 | pending = false; 13 | flushPending(); 14 | }); 15 | } 16 | }); 17 | 18 | function flushPending() { 19 | for (const signal of watcher.getPending()) { 20 | signal.get(); 21 | } 22 | 23 | // Keep watching... we don't know when we're allowed to stop watching 24 | watcher.watch(); 25 | } 26 | 27 | /** 28 | * ⚠️ WARNING: Nothing unwatches ⚠️ 29 | * This will produce a memory leak. 30 | */ 31 | export function effect(cb: () => void) { 32 | let c = new Signal.Computed(() => cb()); 33 | 34 | watcher.watch(c); 35 | 36 | c.get(); 37 | 38 | return () => { 39 | watcher.unwatch(c); 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/subtle/reaction.ts: -------------------------------------------------------------------------------- 1 | import { Signal } from "signal-polyfill"; 2 | 3 | export const __internal_testing__ = { 4 | active: false, 5 | lastError: null, 6 | } as { active: boolean; lastError: null | unknown }; 7 | 8 | class ReactionError { 9 | original: unknown; 10 | name = "ReactionError"; 11 | 12 | constructor(original: unknown) { 13 | this.original = original; 14 | } 15 | } 16 | 17 | /** 18 | * Reactions are a way to observe a value and run an effect when it changes. 19 | * 20 | * The `data` function is run and tracked in a computed signal. It returns a 21 | * value that is compared to the previous value. If the value changes, the 22 | * `effect` function is called with the new value and the previous value. 23 | * 24 | * @param data A function that returns the value to observe. 25 | * @param effect A function that is called when the value changes. 26 | * @param equals A function that compares two values for equality. 27 | * @returns A function that stops the reaction. 28 | */ 29 | export const reaction = ( 30 | data: () => T, 31 | effect: (value: T, previousValue: T) => void, 32 | equals = Object.is, 33 | ) => { 34 | // Passing equals here doesn't seem to dedupe the effect calls. 35 | const computed: Signal.Computed = new Signal.Computed(data, { 36 | equals, 37 | }); 38 | let previousValue = computed.get(); 39 | let notify: (() => Promise) | undefined = async () => { 40 | // await 0 is a cheap way to queue a microtask 41 | await 0; 42 | // Check if this reaction was unsubscribed 43 | if (notify === undefined) { 44 | return; 45 | } 46 | const value = computed.get(); 47 | if (!equals(value, previousValue)) { 48 | try { 49 | effect(value, previousValue); 50 | } catch (e) { 51 | // TODO: we actually want this to be unhandled, but Vitest complains. 52 | // We probably don't want to enable dangerouslyIgnoreUnhandledErrors 53 | // for all tests 54 | if (__internal_testing__) { 55 | console.error(e); 56 | __internal_testing__.lastError = e; 57 | } else { 58 | throw new ReactionError(e); 59 | } 60 | } finally { 61 | previousValue = value; 62 | } 63 | } 64 | watcher.watch(); 65 | }; 66 | const watcher = new Signal.subtle.Watcher(() => notify?.()); 67 | watcher.watch(computed); 68 | 69 | return () => { 70 | watcher.unwatch(computed); 71 | // TODO: Do we need this? Add a memory leak test. 72 | // By severing the reference to the notify function, we allow the garbage 73 | // collector to clean up the resources used by the watcher. 74 | notify = undefined; 75 | }; 76 | }; 77 | -------------------------------------------------------------------------------- /src/weak-map.ts: -------------------------------------------------------------------------------- 1 | import { createStorage, type StorageWeakMap } from "./-private/util.ts"; 2 | 3 | export class SignalWeakMap 4 | implements WeakMap 5 | { 6 | private storages: StorageWeakMap = new WeakMap(); 7 | 8 | private vals: WeakMap; 9 | 10 | private readStorageFor(key: K): void { 11 | const { storages } = this; 12 | let storage = storages.get(key); 13 | 14 | if (storage === undefined) { 15 | storage = createStorage(); 16 | storages.set(key, storage); 17 | } 18 | 19 | storage.get(); 20 | } 21 | 22 | private dirtyStorageFor(key: K): void { 23 | const storage = this.storages.get(key); 24 | 25 | if (storage) { 26 | storage.set(null); 27 | } 28 | } 29 | 30 | constructor(); 31 | constructor(iterable: Iterable); 32 | constructor(entries: readonly [K, V][] | null); 33 | constructor( 34 | existing?: readonly [K, V][] | Iterable | null | undefined, 35 | ) { 36 | // TypeScript doesn't correctly resolve the overloads for calling the `Map` 37 | // constructor for the no-value constructor. This resolves that. 38 | this.vals = existing ? new WeakMap(existing) : new WeakMap(); 39 | } 40 | 41 | get(key: K): V | undefined { 42 | this.readStorageFor(key); 43 | 44 | return this.vals.get(key); 45 | } 46 | 47 | has(key: K): boolean { 48 | this.readStorageFor(key); 49 | 50 | return this.vals.has(key); 51 | } 52 | 53 | set(key: K, value: V): this { 54 | this.dirtyStorageFor(key); 55 | 56 | this.vals.set(key, value); 57 | 58 | return this; 59 | } 60 | 61 | delete(key: K): boolean { 62 | this.dirtyStorageFor(key); 63 | 64 | return this.vals.delete(key); 65 | } 66 | 67 | get [Symbol.toStringTag](): string { 68 | return this.vals[Symbol.toStringTag]; 69 | } 70 | } 71 | 72 | // So instanceof works 73 | Object.setPrototypeOf(SignalWeakMap.prototype, WeakMap.prototype); 74 | -------------------------------------------------------------------------------- /src/weak-set.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createStorage, 3 | type StorageWeakMap, 4 | type Storage, 5 | } from "./-private/util.ts"; 6 | 7 | export class SignalWeakSet implements WeakSet { 8 | private storages: StorageWeakMap = new WeakMap(); 9 | 10 | private vals: WeakSet; 11 | 12 | private storageFor(key: T): Storage { 13 | const storages = this.storages; 14 | let storage = storages.get(key); 15 | 16 | if (storage === undefined) { 17 | storage = createStorage(); 18 | storages.set(key, storage); 19 | } 20 | 21 | return storage; 22 | } 23 | 24 | private dirtyStorageFor(key: T): void { 25 | const storage = this.storages.get(key); 26 | 27 | if (storage) { 28 | storage.set(null); 29 | } 30 | } 31 | 32 | constructor(values?: readonly T[] | null) { 33 | this.vals = new WeakSet(values); 34 | } 35 | 36 | has(value: T): boolean { 37 | this.storageFor(value).get(); 38 | 39 | return this.vals.has(value); 40 | } 41 | 42 | add(value: T): this { 43 | // Add to vals first to get better error message 44 | this.vals.add(value); 45 | 46 | this.dirtyStorageFor(value); 47 | 48 | return this; 49 | } 50 | 51 | delete(value: T): boolean { 52 | this.dirtyStorageFor(value); 53 | 54 | return this.vals.delete(value); 55 | } 56 | 57 | get [Symbol.toStringTag](): string { 58 | return this.vals[Symbol.toStringTag]; 59 | } 60 | } 61 | 62 | // So instanceof works 63 | Object.setPrototypeOf(SignalWeakSet.prototype, WeakSet.prototype); 64 | -------------------------------------------------------------------------------- /tests-public/babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-typescript"], 3 | "plugins": [ 4 | ["@babel/plugin-transform-typescript", { "allowDeclareFields": true }], 5 | ["@babel/plugin-proposal-decorators", { "version": "2023-11" }] 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /tests-public/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "signal-utils-tests", 3 | "private": true, 4 | "scripts": { 5 | "lint": "tsc --noEmit && prettier --check .", 6 | "format": "prettier --write .", 7 | "test": "vitest" 8 | }, 9 | "author": "NullVoxPopuli", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "@babel/core": "^7.24.4", 13 | "@babel/plugin-proposal-decorators": "^7.24.1", 14 | "@babel/plugin-syntax-decorators": "^7.24.1", 15 | "@babel/plugin-transform-typescript": "^7.24.4", 16 | "@babel/preset-typescript": "^7.24.1", 17 | "@rollup/plugin-babel": "^6.0.4", 18 | "@tsconfig/strictest": "^2.0.5", 19 | "@vitest/browser": "^1.4.0", 20 | "@vitest/ui": "^1.4.0", 21 | "expect-type": "^0.19.0", 22 | "globby": "^14.0.1", 23 | "prettier": "^3.2.5", 24 | "signal-polyfill": "^0.1.0", 25 | "signal-utils": "workspace:*", 26 | "typescript": "^5.4.3", 27 | "vite": "^5.2.8", 28 | "vitest": "^1.4.0", 29 | "webdriverio": "^8.35.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests-public/public-api.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, assert } from "vitest"; 2 | 3 | import { signal } from "signal-utils"; 4 | import { signalObject, SignalObject } from "signal-utils/object"; 5 | import { signalArray, SignalArray } from "signal-utils/array"; 6 | import { load, SignalAsyncData } from "signal-utils/async-data"; 7 | 8 | describe("Public API", () => { 9 | it("exists", () => { 10 | class State { 11 | @signal accessor a = 2; 12 | @signal accessor b = "str"; 13 | } 14 | 15 | let state = new State(); 16 | 17 | assert.ok(state); 18 | assert.ok(new SignalObject()); 19 | assert.ok(signalObject()); 20 | assert.ok(new SignalArray()); 21 | assert.ok(signalArray()); 22 | assert.ok(SignalArray.from([])); 23 | assert.ok(load(Promise.resolve())); 24 | assert.ok(new SignalAsyncData(Promise.resolve())); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /tests-public/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "@tsconfig/strictest", 4 | "compilerOptions": { 5 | "target": "esnext", 6 | "module": "preserve", 7 | "moduleResolution": "bundler", 8 | 9 | /** 10 | https://www.typescriptlang.org/tsconfig#allowImportingTsExtensions 11 | 12 | We want our tooling to know how to resolve our custom files so the appropriate plugins 13 | can do the proper transformations on those files. 14 | */ 15 | "allowImportingTsExtensions": true, 16 | 17 | /** 18 | We don't want to include types dependencies in our compiled output, so tell TypeScript 19 | to enforce using `import type` instead of `import` for Types. 20 | */ 21 | "verbatimModuleSyntax": true, 22 | 23 | /** 24 | Don't implicitly pull in declarations from `@types` packages unless we 25 | actually import from them AND the package in question doesn't bring its 26 | own types. You may wish to override this e.g. with `"types": ["node"]` 27 | if your project has build-time elements that use NodeJS APIs. 28 | */ 29 | "types": [] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests-public/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { babel } from "@rollup/plugin-babel"; 3 | 4 | export default defineConfig({ 5 | // esbuild in vite does not support decorators 6 | // https://github.com/evanw/esbuild/issues/104 7 | esbuild: false, 8 | plugins: [ 9 | babel({ 10 | babelHelpers: "inline", 11 | extensions: [".js", ".ts"], 12 | }), 13 | ], 14 | }); 15 | -------------------------------------------------------------------------------- /tests/@localCopy.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, assert } from "vitest"; 2 | import { localCopy } from "../src/local-copy.ts"; 3 | import { assertReactivelySettled } from "./helpers.ts"; 4 | 5 | describe("@localCopy", () => { 6 | test("it works", function () { 7 | class Remote { 8 | value = 123; 9 | } 10 | 11 | let remote = new Remote(); 12 | 13 | class Local { 14 | remote = remote; 15 | 16 | @localCopy("remote.value") accessor value!: number; 17 | } 18 | 19 | let local = new Local(); 20 | 21 | assert.strictEqual(local.value, 123, "defaults to the remote value"); 22 | 23 | assertReactivelySettled({ 24 | access: () => local.value, 25 | change: () => (local.value = 456), 26 | }); 27 | 28 | assert.strictEqual(local.value, 456, "local value updates correctly"); 29 | assert.strictEqual(remote.value, 123, "remote value does not update"); 30 | 31 | remote.value = 789; 32 | 33 | assert.strictEqual( 34 | local.value, 35 | 789, 36 | "local value updates to new remote value", 37 | ); 38 | assert.strictEqual(remote.value, 789, "remote value is updated"); 39 | }); 40 | 41 | test("it requires a path or getter", function () { 42 | assert.throws(() => { 43 | class Local { 44 | // @ts-expect-error 45 | @localCopy accessor value; 46 | } 47 | 48 | new Local(); 49 | }, /@localCopy\(\) must be given a memo path/); 50 | }); 51 | 52 | test("value initializer works", function () { 53 | class Remote { 54 | value: unknown; 55 | } 56 | 57 | let remote = new Remote(); 58 | 59 | class Local { 60 | remote = remote; 61 | 62 | @localCopy("remote.value", 123) accessor value!: number; 63 | } 64 | 65 | let local = new Local(); 66 | 67 | assert.strictEqual(local.value, 123, "defaults to the initializer value"); 68 | assert.strictEqual(remote.value, undefined, "remote value is undefined"); 69 | 70 | local.value = 456; 71 | 72 | assert.strictEqual(local.value, 456, "local value updates correctly"); 73 | assert.strictEqual(remote.value, undefined, "remote value does not update"); 74 | 75 | remote.value = 789; 76 | 77 | assert.strictEqual( 78 | local.value, 79 | 789, 80 | "local value updates to new remote value", 81 | ); 82 | assert.strictEqual(remote.value, 789, "remote value is updated"); 83 | }); 84 | 85 | test("function initializer works", function () { 86 | class Remote { 87 | value: unknown; 88 | } 89 | 90 | let remote = new Remote(); 91 | 92 | class Local { 93 | remote = remote; 94 | 95 | @localCopy("remote.value", () => 123) accessor value!: number; 96 | } 97 | 98 | let local = new Local(); 99 | 100 | assert.strictEqual(local.value, 123, "defaults to the initializer value"); 101 | assert.strictEqual(remote.value, undefined, "remote value is undefined"); 102 | 103 | local.value = 456; 104 | 105 | assert.strictEqual(local.value, 456, "local value updates correctly"); 106 | assert.strictEqual(remote.value, undefined, "remote value does not update"); 107 | 108 | remote.value = 789; 109 | 110 | assert.strictEqual( 111 | local.value, 112 | 789, 113 | "local value updates to new remote value", 114 | ); 115 | assert.strictEqual(remote.value, 789, "remote value is updated"); 116 | }); 117 | 118 | test("it works when setting the value locally before accessing it", function () { 119 | class Remote { 120 | value = 123; 121 | } 122 | 123 | let remote = new Remote(); 124 | 125 | class Local { 126 | remote = remote; 127 | 128 | @localCopy("remote.value") accessor value!: number; 129 | } 130 | 131 | let local = new Local(); 132 | 133 | // set the value before reading it 134 | local.value = 456; 135 | 136 | assert.strictEqual(local.value, 456, "local value updates correctly"); 137 | assert.strictEqual(remote.value, 123, "remote value does not update"); 138 | 139 | remote.value = 789; 140 | 141 | assert.strictEqual( 142 | local.value, 143 | 789, 144 | "local value updates to new remote value", 145 | ); 146 | assert.strictEqual(remote.value, 789, "remote value is updated"); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /tests/@signal.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, assert } from "vitest"; 2 | 3 | import { assertStable, assertReactivelySettled } from "./helpers.ts"; 4 | import { signal } from "../src/index.ts"; 5 | 6 | describe("@signal (accessor)", () => { 7 | it("works", () => { 8 | class State { 9 | @signal accessor #value = 3; 10 | 11 | get doubled() { 12 | return this.#value * 2; 13 | } 14 | 15 | increment = () => this.#value++; 16 | } 17 | 18 | let state = new State(); 19 | 20 | assertReactivelySettled({ 21 | access: () => state.doubled, 22 | change: () => state.increment(), 23 | }); 24 | 25 | assertStable(() => state.doubled); 26 | }); 27 | }); 28 | 29 | describe("@signal (getter)", () => { 30 | it("works", () => { 31 | let cachedCalls = 0; 32 | 33 | class State { 34 | @signal accessor #value = 3; 35 | 36 | @signal 37 | get doubled() { 38 | cachedCalls++; 39 | return this.#value * 2; 40 | } 41 | 42 | increment = () => this.#value++; 43 | } 44 | 45 | let state = new State(); 46 | 47 | assert.strictEqual(cachedCalls, 0); 48 | 49 | assertReactivelySettled({ 50 | access: () => state.doubled, 51 | change: () => state.increment(), 52 | }); 53 | 54 | assert.strictEqual(cachedCalls, 2); 55 | 56 | assertStable(() => state.doubled); 57 | 58 | // No more evaluation of the getter 59 | assert.strictEqual(cachedCalls, 2); 60 | state.doubled; 61 | assert.strictEqual(cachedCalls, 2); 62 | state.doubled; 63 | assert.strictEqual(cachedCalls, 2); 64 | }); 65 | 66 | it("errors when used with a setter", () => { 67 | assert.throws(() => { 68 | class State { 69 | #value = 3; 70 | 71 | @signal 72 | get doubled() { 73 | return this.#value * 2; 74 | } 75 | // Deliberate incorrect usage to test a runtime error 76 | // @ts-expect-error 77 | @signal 78 | set doubled(_v) { 79 | // what would we even set 80 | } 81 | } 82 | 83 | new State(); 84 | }, /@signal can only be used on accessors or getters/); 85 | }); 86 | 87 | it("not shared between instances", () => { 88 | class State { 89 | @signal accessor #value = 3; 90 | 91 | @signal 92 | get doubled() { 93 | return this.#value * 2; 94 | } 95 | 96 | constructor(value: number) { 97 | this.#value = value; 98 | } 99 | } 100 | 101 | let state1 = new State(1); 102 | let state2 = new State(2); 103 | 104 | assert.strictEqual(state1.doubled, 2); 105 | assert.strictEqual(state2.doubled, 4); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /tests/array-map.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, assert } from "vitest"; 2 | 3 | import { signal } from "../src/index.ts"; 4 | import { arrayMap } from "../src/array-map.ts"; 5 | 6 | describe("arrayMap", function () { 7 | class Wrapper { 8 | constructor(public record: unknown) {} 9 | } 10 | 11 | interface TestRecord { 12 | id: number; 13 | someProp?: string; 14 | } 15 | 16 | class ExampleTrackedThing { 17 | @signal accessor id: number; 18 | @signal accessor someValue = ""; 19 | 20 | constructor(id: number) { 21 | this.id = id; 22 | } 23 | } 24 | 25 | function testData(id: number) { 26 | return new ExampleTrackedThing(id); 27 | } 28 | 29 | test(`it works`, function () { 30 | let steps: string[] = []; 31 | 32 | const verifySteps = (expected: string[], message: string) => { 33 | assert.deepEqual(steps, expected, message); 34 | // clear 35 | steps.length = 0; 36 | }; 37 | 38 | class Test { 39 | @signal accessor records: TestRecord[] = []; 40 | } 41 | 42 | let currentStuff: Wrapper[] = []; 43 | let instance = new Test(); 44 | 45 | const stuff = arrayMap({ 46 | data: () => { 47 | steps.push("evaluate data thunk"); 48 | 49 | return instance.records; 50 | }, 51 | map: (record) => { 52 | steps.push(`perform map on ${record.id}`); 53 | 54 | return new Wrapper(record); 55 | }, 56 | }); 57 | 58 | const get = (index: number) => stuff[index]; 59 | 60 | assert.strictEqual(stuff.length, 0); 61 | verifySteps( 62 | ["evaluate data thunk"], 63 | "❯❯ initially, the data fn is consumed", 64 | ); 65 | 66 | let first = testData(1); 67 | let second = testData(2); 68 | 69 | instance.records = [first, second]; 70 | assert.strictEqual(stuff.length, 2, "length adjusted"); 71 | verifySteps( 72 | ["evaluate data thunk"], 73 | "❯❯ we do not map yet because the data has not been accessed", 74 | ); 75 | 76 | assert.ok(get(0) instanceof Wrapper, "access id:1"); 77 | assert.ok(get(1) instanceof Wrapper, "access id:2"); 78 | verifySteps( 79 | ["perform map on 1", "perform map on 2"], 80 | "❯❯ accessing indicies calls the mapper", 81 | ); 82 | 83 | assert.ok(get(0) instanceof Wrapper, "access id:1"); 84 | assert.ok(get(1) instanceof Wrapper, "access id:2"); 85 | verifySteps([], "❯❯ re-access is a no-op"); 86 | 87 | // this tests the iterator 88 | currentStuff = [...stuff]; 89 | assert.ok(stuff.values()[0] instanceof Wrapper, "mappedRecords id:1"); 90 | assert.ok(stuff.values()[1] instanceof Wrapper, "mappedRecords id:2"); 91 | 92 | assert.strictEqual( 93 | currentStuff[0]?.record, 94 | first, 95 | "object equality retained", 96 | ); 97 | assert.strictEqual( 98 | currentStuff[1]?.record, 99 | second, 100 | "object equality retained", 101 | ); 102 | 103 | instance.records = [...instance.records, testData(3)]; 104 | assert.strictEqual(stuff.length, 3, "length adjusted"); 105 | verifySteps( 106 | ["evaluate data thunk"], 107 | "❯❯ we do not map on the new object yet because the data has not been accessed", 108 | ); 109 | 110 | assert.ok(get(0) instanceof Wrapper, "access id:1"); 111 | assert.ok(get(1) instanceof Wrapper, "access id:2"); 112 | assert.ok(get(2) instanceof Wrapper, "access id:3"); 113 | assert.strictEqual(get(0), currentStuff[0], "original objects retained"); 114 | assert.strictEqual(get(1), currentStuff[1], "original objects retained"); 115 | verifySteps( 116 | ["perform map on 3"], 117 | "❯❯ only calls map once, even though the whole source data was re-created", 118 | ); 119 | 120 | first.someValue = "throwaway value"; 121 | verifySteps( 122 | [], 123 | "❯❯ data thunk is not ran, because the tracked data consumed in the thunk was not changed", 124 | ); 125 | assert.strictEqual(get(0), currentStuff[0], "original objects retained"); 126 | assert.strictEqual(get(1), currentStuff[1], "original objects retained"); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /tests/array.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, assert } from "vitest"; 2 | import { SignalArray } from "../src/array.ts"; 3 | import { expectTypeOf } from "expect-type"; 4 | import { assertReactivelySettled, reactivityTest } from "./helpers"; 5 | 6 | const ARRAY_GETTER_METHODS = [ 7 | "concat", 8 | "entries", 9 | "every", 10 | "filter", 11 | "find", 12 | "findIndex", 13 | "flat", 14 | "flatMap", 15 | "forEach", 16 | "includes", 17 | "indexOf", 18 | "join", 19 | "keys", 20 | "lastIndexOf", 21 | "map", 22 | "reduce", 23 | "reduceRight", 24 | "slice", 25 | "some", 26 | "values", 27 | ]; 28 | 29 | const ARRAY_SETTER_METHODS = [ 30 | "copyWithin", 31 | "fill", 32 | "pop", 33 | "push", 34 | "reverse", 35 | "shift", 36 | "sort", 37 | "splice", 38 | "unshift", 39 | ]; 40 | 41 | // We can use a `SignalArray` anywhere we can use an `Array` (but not 42 | // vice versa). 43 | expectTypeOf>().toMatchTypeOf>(); 44 | 45 | describe("SignalArray", function () { 46 | test("Can get values on array directly", () => { 47 | let arr = new SignalArray(["foo"]); 48 | 49 | assert.equal(arr[0], "foo"); 50 | }); 51 | 52 | test("Can get length on array directly", () => { 53 | let arr = new SignalArray(["foo"]); 54 | 55 | assert.equal(arr.length, 1); 56 | }); 57 | 58 | test("Can set values on array directly", () => { 59 | let arr = new SignalArray(); 60 | arr[0] = 123; 61 | 62 | assert.equal(arr[0], 123); 63 | }); 64 | 65 | test("Can set length on array directly", () => { 66 | let arr = new SignalArray(); 67 | arr.length = 123; 68 | 69 | assert.equal(arr.length, 123); 70 | }); 71 | 72 | test("Can clear array by setting length to 0", () => { 73 | let arr = new SignalArray([123]); 74 | arr.length = 0; 75 | 76 | assert.equal(arr.length, 0); 77 | assert.equal(arr[0], undefined); 78 | }); 79 | 80 | describe("methods", () => { 81 | test("isArray", () => { 82 | let arr = new SignalArray(); 83 | 84 | assert.ok(Array.isArray(arr)); 85 | }); 86 | 87 | test("length", () => { 88 | let arr = new SignalArray(); 89 | 90 | assert.equal(arr.length, 0); 91 | 92 | arr[100] = 1; 93 | 94 | assert.equal(arr.length, 101); 95 | }); 96 | 97 | test("concat", () => { 98 | let arr = new SignalArray(); 99 | let arr2 = arr.concat([1], new SignalArray([2])); 100 | 101 | assert.deepEqual(arr2, [1, 2]); 102 | assert.notOk(arr2 instanceof SignalArray); 103 | }); 104 | 105 | test("copyWithin", () => { 106 | let arr = new SignalArray([1, 2, 3]); 107 | arr.copyWithin(1, 0, 1); 108 | 109 | assert.deepEqual(arr, [1, 1, 3]); 110 | }); 111 | 112 | test("entries", () => { 113 | let arr = new SignalArray([1, 2, 3]); 114 | let iter = arr.entries(); 115 | 116 | assert.deepEqual(iter.next().value, [0, 1]); 117 | assert.deepEqual(iter.next().value, [1, 2]); 118 | assert.deepEqual(iter.next().value, [2, 3]); 119 | assert.equal(iter.next().done, true); 120 | }); 121 | 122 | test("every", () => { 123 | let arr = new SignalArray([1, 2, 3]); 124 | 125 | assert.ok(arr.every((v) => typeof v === "number")); 126 | assert.notOk(arr.every((v) => v !== 2)); 127 | }); 128 | 129 | test("fill", () => { 130 | let arr = new SignalArray(); 131 | arr.length = 100; 132 | arr.fill(123); 133 | 134 | let count = 0; 135 | let isCorrect = true; 136 | 137 | for (let value of arr) { 138 | count++; 139 | isCorrect = isCorrect && value === 123; 140 | } 141 | 142 | assert.equal(count, 100); 143 | assert.ok(isCorrect); 144 | }); 145 | 146 | test("filter", () => { 147 | let arr = new SignalArray([1, 2, 3]); 148 | let arr2 = arr.filter((v) => v > 1); 149 | 150 | assert.deepEqual(arr2, [2, 3]); 151 | assert.notOk(arr2 instanceof SignalArray); 152 | }); 153 | 154 | test("find", () => { 155 | let arr = new SignalArray([1, 2, 3]); 156 | 157 | assert.equal( 158 | arr.find((v) => v > 1), 159 | 2, 160 | ); 161 | }); 162 | 163 | test("findIndex", () => { 164 | let arr = new SignalArray([1, 2, 3]); 165 | 166 | assert.equal( 167 | arr.findIndex((v) => v > 1), 168 | 1, 169 | ); 170 | }); 171 | 172 | test("flat", () => { 173 | let arr = new SignalArray([1, 2, [3]]); 174 | 175 | assert.deepEqual(arr.flat(), [1, 2, 3]); 176 | assert.deepEqual(arr, [1, 2, [3]]); 177 | }); 178 | 179 | test("flatMap", () => { 180 | let arr = new SignalArray([1, 2, [3]]); 181 | 182 | assert.deepEqual( 183 | arr.flatMap((v) => (typeof v === "number" ? v + 1 : v)), 184 | [2, 3, 3], 185 | ); 186 | assert.deepEqual(arr, [1, 2, [3]]); 187 | }); 188 | 189 | test("forEach", () => { 190 | let arr = new SignalArray([1, 2, 3]); 191 | 192 | arr.forEach((v, i) => assert.equal(v, i + 1)); 193 | }); 194 | 195 | test("includes", () => { 196 | let arr = new SignalArray([1, 2, 3]); 197 | 198 | assert.equal(arr.includes(1), true); 199 | assert.equal(arr.includes(5), false); 200 | }); 201 | 202 | test("indexOf", () => { 203 | let arr = new SignalArray([1, 2, 1]); 204 | 205 | assert.equal(arr.indexOf(1), 0); 206 | assert.equal(arr.indexOf(5), -1); 207 | }); 208 | 209 | test("join", () => { 210 | let arr = new SignalArray([1, 2, 3]); 211 | 212 | assert.equal(arr.join(","), "1,2,3"); 213 | }); 214 | 215 | test("keys", () => { 216 | let arr = new SignalArray([1, 2, 3]); 217 | let iter = arr.keys(); 218 | 219 | assert.equal(iter.next().value, 0); 220 | assert.equal(iter.next().value, 1); 221 | assert.equal(iter.next().value, 2); 222 | assert.equal(iter.next().done, true); 223 | }); 224 | 225 | test("lastIndexOf", () => { 226 | let arr = new SignalArray([3, 2, 3]); 227 | 228 | assert.equal(arr.lastIndexOf(3), 2); 229 | assert.equal(arr.lastIndexOf(5), -1); 230 | }); 231 | 232 | test("map", () => { 233 | let arr = new SignalArray([1, 2, 3]); 234 | let arr2 = arr.map((v) => v + 1); 235 | 236 | assert.deepEqual(arr2, [2, 3, 4]); 237 | assert.notOk(arr2 instanceof SignalArray); 238 | }); 239 | 240 | test("pop", () => { 241 | let arr = new SignalArray([1, 2, 3]); 242 | let val = arr.pop(); 243 | 244 | assert.deepEqual(arr, [1, 2]); 245 | assert.equal(val, 3); 246 | }); 247 | 248 | test("push", () => { 249 | let arr = new SignalArray([1, 2, 3]); 250 | let val = arr.push(4); 251 | 252 | assert.deepEqual(arr, [1, 2, 3, 4]); 253 | assert.equal(val, 4); 254 | }); 255 | 256 | test("reduce", () => { 257 | let arr = new SignalArray([1, 2, 3]); 258 | 259 | assert.equal( 260 | arr.reduce((s, v) => s + v, ""), 261 | "123", 262 | ); 263 | }); 264 | 265 | test("reduceRight", () => { 266 | let arr = new SignalArray([1, 2, 3]); 267 | 268 | assert.equal( 269 | arr.reduceRight((s, v) => s + v, ""), 270 | "321", 271 | ); 272 | }); 273 | 274 | test("reverse", () => { 275 | let arr = new SignalArray([1, 2, 3]); 276 | arr.reverse(); 277 | 278 | assert.deepEqual(arr, [3, 2, 1]); 279 | }); 280 | 281 | test("shift", () => { 282 | let arr = new SignalArray([1, 2, 3]); 283 | let val = arr.shift(); 284 | 285 | assert.deepEqual(arr, [2, 3]); 286 | assert.equal(val, 1); 287 | }); 288 | 289 | test("slice", () => { 290 | let arr = new SignalArray([1, 2, 3]); 291 | let arr2 = arr.slice(); 292 | 293 | assert.notEqual(arr, arr2); 294 | assert.notOk(arr2 instanceof SignalArray); 295 | assert.deepEqual(arr, arr2); 296 | }); 297 | 298 | test("some", () => { 299 | let arr = new SignalArray([1, 2, 3]); 300 | 301 | assert.ok(arr.some((v) => v > 1)); 302 | assert.notOk(arr.some((v) => v < 1)); 303 | }); 304 | 305 | test("sort", () => { 306 | let arr = new SignalArray([3, 1, 2]); 307 | let arr2 = arr.sort(); 308 | 309 | assert.equal(arr, arr2); 310 | assert.deepEqual(arr, [1, 2, 3]); 311 | }); 312 | 313 | test("sort (with method)", () => { 314 | let arr = new SignalArray([3, 1, 2, 2]); 315 | let arr2 = arr.sort((a, b) => { 316 | if (a > b) return -1; 317 | if (a < b) return 1; 318 | return 0; 319 | }); 320 | 321 | assert.equal(arr, arr2); 322 | assert.deepEqual(arr, [3, 2, 2, 1]); 323 | }); 324 | 325 | test("splice", () => { 326 | let arr = new SignalArray([1, 2, 3]); 327 | let arr2 = arr.splice(1, 1); 328 | 329 | assert.notOk(arr2 instanceof SignalArray); 330 | assert.deepEqual(arr, [1, 3]); 331 | assert.deepEqual(arr2, [2]); 332 | }); 333 | 334 | test("unshift", () => { 335 | let arr = new SignalArray([1, 2, 3]); 336 | let val = arr.unshift(0); 337 | 338 | assert.deepEqual(arr, [0, 1, 2, 3]); 339 | assert.equal(val, 4); 340 | }); 341 | 342 | test("values", () => { 343 | let arr = new SignalArray([1, 2, 3]); 344 | let iter = arr.values(); 345 | 346 | assert.equal(iter.next().value, 1); 347 | assert.equal(iter.next().value, 2); 348 | assert.equal(iter.next().value, 3); 349 | assert.equal(iter.next().done, true); 350 | }); 351 | 352 | test("of", () => { 353 | let arr = SignalArray.of(1, 2, 3); 354 | 355 | assert.deepEqual(arr, [1, 2, 3]); 356 | }); 357 | 358 | test("from", () => { 359 | let arr = SignalArray.from([1, 2, 3]); 360 | 361 | assert.deepEqual(arr, [1, 2, 3]); 362 | }); 363 | }); 364 | 365 | describe("reactivity", () => { 366 | test("reassignment is stable", () => { 367 | let arr = SignalArray.from([1, 2, 3]); 368 | 369 | assertReactivelySettled({ 370 | access: () => arr[0], 371 | change: () => (arr[0] = 4), 372 | }); 373 | }); 374 | 375 | test("length is stable: push", () => { 376 | let arr = SignalArray.from([1, 2, 3]); 377 | 378 | assertReactivelySettled({ 379 | access: () => arr.length, 380 | change: () => arr.push(4), 381 | }); 382 | }); 383 | 384 | test("length is stable: pop", () => { 385 | let arr = SignalArray.from([1, 2, 3]); 386 | 387 | assertReactivelySettled({ 388 | access: () => arr.length, 389 | change: () => arr.pop(), 390 | }); 391 | }); 392 | 393 | test("length is stable: unshift", () => { 394 | let arr = SignalArray.from([1, 2, 3]); 395 | 396 | assertReactivelySettled({ 397 | access: () => arr.length, 398 | change: () => arr.unshift(0), 399 | }); 400 | }); 401 | 402 | test("length is stable: shift", () => { 403 | let arr = SignalArray.from([1, 2, 3]); 404 | 405 | assertReactivelySettled({ 406 | access: () => arr.length, 407 | change: () => arr.shift(), 408 | }); 409 | }); 410 | 411 | ARRAY_GETTER_METHODS.forEach((method) => { 412 | reactivityTest( 413 | `${method} individual index`, 414 | class State { 415 | arr = new SignalArray(["foo", "bar"]); 416 | 417 | get value() { 418 | // @ts-ignore -- this can't be represented easily in TS, and we 419 | // don't actually care that it is; we're *just* testing reactivity. 420 | return this.arr[method](() => { 421 | /* no op */ 422 | }); 423 | } 424 | 425 | update() { 426 | this.arr[0] = "bar"; 427 | } 428 | }, 429 | ); 430 | 431 | reactivityTest( 432 | `${method} collection tag`, 433 | class State { 434 | arr = new SignalArray(["foo", "bar"]); 435 | 436 | get value() { 437 | // @ts-ignore -- this can't be represented easily in TS, and we 438 | // don't actually care that it is; we're *just* testing reactivity. 439 | return this.arr[method](() => { 440 | /* no op */ 441 | }); 442 | } 443 | 444 | update() { 445 | this.arr.sort(); 446 | } 447 | }, 448 | ); 449 | }); 450 | 451 | ARRAY_SETTER_METHODS.forEach((method) => { 452 | reactivityTest( 453 | `${method} individual index`, 454 | class { 455 | arr = new SignalArray(["foo", "bar"]); 456 | 457 | get value() { 458 | return this.arr[0]; 459 | } 460 | 461 | update() { 462 | // @ts-ignore -- this can't be represented easily in TS, and we 463 | // don't actually care that it is; we're *just* testing reactivity. 464 | this.arr[method](undefined); 465 | } 466 | }, 467 | ); 468 | 469 | reactivityTest( 470 | `${method} collection tag`, 471 | class { 472 | arr = new SignalArray(["foo", "bar"]); 473 | 474 | get value() { 475 | return this.arr.forEach(() => { 476 | /* no op */ 477 | }); 478 | } 479 | 480 | update() { 481 | // @ts-ignore -- this can't be represented easily in TS, and we 482 | // don't actually care that it is; we're *just* testing reactivity. 483 | this.arr[method](undefined); 484 | } 485 | }, 486 | ); 487 | }); 488 | }); 489 | }); 490 | -------------------------------------------------------------------------------- /tests/async-computed.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, assert } from "vitest"; 2 | import { Signal } from "signal-polyfill"; 3 | import { AsyncComputed } from "../src/async-computed.ts"; 4 | 5 | describe("AsyncComputed", () => { 6 | test("initialValue", async () => { 7 | const task = new AsyncComputed(async () => 1, { initialValue: 0 }); 8 | assert.strictEqual(task.value, 0); 9 | }); 10 | 11 | test("AsyncComputed runs", async () => { 12 | const task = new AsyncComputed(async () => { 13 | // Make the task take more than one microtask 14 | await 0; 15 | return 1; 16 | }); 17 | 18 | // Getting the value (or status and other properties) starts the task 19 | assert.equal(task.status, "pending"); 20 | assert.strictEqual(task.value, undefined); 21 | assert.strictEqual(task.error, undefined); 22 | assert.equal(task.status, "pending"); 23 | 24 | const result = await task.complete; 25 | 26 | assert.equal(task.status, "complete"); 27 | assert.strictEqual(task.value, 1); 28 | assert.strictEqual(result, 1); 29 | assert.strictEqual(task.error, undefined); 30 | }); 31 | 32 | test("AsyncComputed re-runs when signal dependencies change", async () => { 33 | const dep = new Signal.State("a"); 34 | const task = new AsyncComputed(async () => { 35 | // Read dependencies before first await 36 | const value = dep.get(); 37 | return value; 38 | }); 39 | 40 | await task.complete; 41 | assert.equal(task.status, "complete"); 42 | assert.strictEqual(task.value, "a"); 43 | assert.strictEqual(task.error, undefined); 44 | 45 | dep.set("b"); 46 | assert.equal(task.status, "pending"); 47 | 48 | await task.complete; 49 | assert.equal(task.status, "complete"); 50 | assert.strictEqual(task.value, "b"); 51 | assert.strictEqual(task.error, undefined); 52 | 53 | dep.set("c"); 54 | assert.equal(task.status, "pending"); 55 | }); 56 | 57 | test("Preemptive runs reuse the same completed promise", async () => { 58 | const dep = new Signal.State("a"); 59 | const deferredOne = Promise.withResolvers(); 60 | let deferred = deferredOne; 61 | const abortSignals: Array = []; 62 | const task = new AsyncComputed(async (abortSignal) => { 63 | // Read dependencies before first await 64 | const value = dep.get(); 65 | 66 | abortSignals.push(abortSignal); 67 | // Wait until we're told to go. The first run will wait so that the 68 | // second run can preempt it. 69 | await deferred.promise; 70 | return value; 71 | }); 72 | 73 | // Capture the promise that the task will complete 74 | const firstRunComplete = task.complete; 75 | 76 | // Trigger a new run with a new deferred 77 | const deferredTwo = Promise.withResolvers(); 78 | deferred = deferredTwo; 79 | dep.set("b"); 80 | const secondRunComplete = task.complete; 81 | 82 | assert.equal(task.status, "pending"); 83 | assert.strictEqual(abortSignals.length, 2); 84 | assert.strictEqual(abortSignals[0]!.aborted, true); 85 | assert.strictEqual(abortSignals[1]!.aborted, false); 86 | 87 | // We should not have created a new Promise. The first Promise should be 88 | // resolved with the result of the second run. 89 | assert.strictEqual(firstRunComplete, secondRunComplete); 90 | 91 | // Resolve the second run 92 | deferredTwo.resolve(); 93 | const result = await task.complete; 94 | assert.equal(result, "b"); 95 | }); 96 | 97 | test("AsyncComputed errors and can re-run", async () => { 98 | const dep = new Signal.State("a"); 99 | const task = new AsyncComputed(async () => { 100 | // Read dependencies before first await 101 | const value = dep.get(); 102 | await 0; 103 | if (value === "a") { 104 | throw new Error("a"); 105 | } 106 | return value; 107 | }); 108 | 109 | task.run(); 110 | assert.equal(task.status, "pending"); 111 | 112 | try { 113 | await task.complete; 114 | assert.fail("Task should have thrown"); 115 | } catch (error) { 116 | assert.equal(task.status, "error"); 117 | assert.strictEqual(task.value, undefined); 118 | assert.strictEqual(task.error, error); 119 | } 120 | 121 | // Check that the task can re-run after an error 122 | 123 | dep.set("b"); 124 | assert.equal(task.status, "pending"); 125 | await task.complete; 126 | assert.strictEqual(task.value, "b"); 127 | assert.strictEqual(task.error, undefined); 128 | }); 129 | 130 | test("get() throws on error", async () => { 131 | const task = new AsyncComputed(async () => { 132 | throw new Error("A"); 133 | }); 134 | task.run(); 135 | await task.complete.catch(() => {}); 136 | assert.throws(() => task.get()); 137 | }); 138 | 139 | test("can chain a computed signal", async () => { 140 | const dep = new Signal.State("a"); 141 | const task = new AsyncComputed(async () => { 142 | // Read dependencies before first await 143 | const value = dep.get(); 144 | await 0; 145 | if (value === "b") { 146 | throw new Error("b"); 147 | } 148 | return value; 149 | }); 150 | const computed = new Signal.Computed(() => task.get()); 151 | assert.strictEqual(computed.get(), undefined); 152 | 153 | await task.complete; 154 | assert.strictEqual(computed.get(), "a"); 155 | 156 | dep.set("b"); 157 | await task.complete.catch(() => {}); 158 | assert.throws(() => computed.get()); 159 | 160 | dep.set("c"); 161 | await task.complete; 162 | assert.strictEqual(computed.get(), "c"); 163 | }); 164 | 165 | test("can chain an AsyncComputed", async () => { 166 | const dep = new Signal.State("a"); 167 | const task1 = new AsyncComputed(async () => { 168 | // Read dependencies before first await 169 | const value = dep.get(); 170 | await 0; 171 | if (value === "b") { 172 | throw new Error("b"); 173 | } 174 | return value; 175 | }); 176 | const task2 = new AsyncComputed(async () => { 177 | return task1.complete; 178 | }); 179 | 180 | assert.strictEqual(task2.get(), undefined); 181 | assert.strictEqual(task2.status, "pending"); 182 | 183 | await task2.complete; 184 | assert.strictEqual(task2.get(), "a"); 185 | assert.strictEqual(task2.status, "complete"); 186 | 187 | dep.set("b"); 188 | assert.strictEqual(task2.status, "pending"); 189 | await task2.complete.catch(() => {}); 190 | assert.throws(() => task2.get()); 191 | assert.strictEqual(task2.status, "error"); 192 | 193 | dep.set("c"); 194 | assert.strictEqual(task2.status, "pending"); 195 | await task2.complete; 196 | assert.strictEqual(task2.get(), "c"); 197 | assert.strictEqual(task2.status, "complete"); 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /tests/async-data.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, assert } from "vitest"; 2 | import { defer } from "./helpers.ts"; 3 | import { load, SignalAsyncData } from "../src/async-data.ts"; 4 | 5 | describe("Unit | load", function () { 6 | test("given a promise", async function () { 7 | const { promise, resolve } = defer(); 8 | const result = load(promise); 9 | assert.ok( 10 | result instanceof SignalAsyncData, 11 | "it returns a TrackedAsyncData instance", 12 | ); 13 | resolve(); 14 | await promise; 15 | }); 16 | 17 | test("given a plain value", async function () { 18 | const result = load(12); 19 | assert.ok( 20 | result instanceof SignalAsyncData, 21 | "it returns a TrackedAsyncData instance", 22 | ); 23 | }); 24 | }); 25 | 26 | describe("Unit | TrackedAsyncData", function () { 27 | test("cannot be subclassed", function () { 28 | class Subclass extends SignalAsyncData {} 29 | 30 | assert.throws(() => new Subclass(Promise.resolve("nope"))); 31 | }); 32 | 33 | test("is initially PENDING", async function () { 34 | const deferred = defer(); 35 | 36 | const result = new SignalAsyncData(deferred.promise); 37 | assert.strictEqual(result.state, "PENDING"); 38 | assert.strictEqual(result.isPending, true); 39 | assert.strictEqual(result.isResolved, false); 40 | assert.strictEqual(result.isRejected, false); 41 | assert.strictEqual(result.value, null); 42 | assert.strictEqual(result.error, null); 43 | 44 | deferred.resolve(); 45 | await deferred.promise; 46 | }); 47 | 48 | test("it updates to resolved state", async function () { 49 | const deferred = defer(); 50 | const result = new SignalAsyncData(deferred.promise); 51 | 52 | deferred.resolve("foobar"); 53 | await deferred.promise; 54 | 55 | assert.strictEqual(result.state, "RESOLVED"); 56 | assert.strictEqual(result.isPending, false); 57 | assert.strictEqual(result.isResolved, true); 58 | assert.strictEqual(result.isRejected, false); 59 | assert.strictEqual(result.value, "foobar"); 60 | assert.strictEqual(result.error, null); 61 | }); 62 | 63 | describe("it returns resolved state for non-thenable input", function () { 64 | test("undefined", async function () { 65 | const loadUndefined = new SignalAsyncData(undefined); 66 | await loadUndefined; 67 | 68 | assert.strictEqual(loadUndefined.state, "RESOLVED"); 69 | assert.strictEqual(loadUndefined.isPending, false); 70 | assert.strictEqual(loadUndefined.isResolved, true); 71 | assert.strictEqual(loadUndefined.isRejected, false); 72 | assert.strictEqual(loadUndefined.value, undefined); 73 | assert.strictEqual(loadUndefined.error, null); 74 | }); 75 | 76 | test("null", async function () { 77 | const loadNull = new SignalAsyncData(null); 78 | await loadNull; 79 | 80 | assert.strictEqual(loadNull.state, "RESOLVED"); 81 | assert.strictEqual(loadNull.isPending, false); 82 | assert.strictEqual(loadNull.isResolved, true); 83 | assert.strictEqual(loadNull.isRejected, false); 84 | assert.strictEqual(loadNull.value, null); 85 | assert.strictEqual(loadNull.error, null); 86 | }); 87 | 88 | test("non-thenable object", async function () { 89 | const notAThenableObject = { notAThenable: true }; 90 | const loadObject = new SignalAsyncData(notAThenableObject); 91 | await loadObject; 92 | 93 | assert.strictEqual(loadObject.state, "RESOLVED"); 94 | assert.strictEqual(loadObject.isPending, false); 95 | assert.strictEqual(loadObject.isResolved, true); 96 | assert.strictEqual(loadObject.isRejected, false); 97 | assert.strictEqual(loadObject.value, notAThenableObject); 98 | assert.strictEqual(loadObject.error, null); 99 | }); 100 | 101 | test("boolean: true", async function () { 102 | const loadTrue = new SignalAsyncData(true); 103 | await loadTrue; 104 | 105 | assert.strictEqual(loadTrue.state, "RESOLVED"); 106 | assert.strictEqual(loadTrue.isPending, false); 107 | assert.strictEqual(loadTrue.isResolved, true); 108 | assert.strictEqual(loadTrue.isRejected, false); 109 | assert.strictEqual(loadTrue.value, true); 110 | assert.strictEqual(loadTrue.error, null); 111 | }); 112 | 113 | test("boolean: false", async function () { 114 | const loadFalse = new SignalAsyncData(false); 115 | await loadFalse; 116 | 117 | assert.strictEqual(loadFalse.state, "RESOLVED"); 118 | assert.strictEqual(loadFalse.isPending, false); 119 | assert.strictEqual(loadFalse.isResolved, true); 120 | assert.strictEqual(loadFalse.isRejected, false); 121 | assert.strictEqual(loadFalse.value, false); 122 | assert.strictEqual(loadFalse.error, null); 123 | }); 124 | 125 | test("number", async function () { 126 | const loadNumber = new SignalAsyncData(5); 127 | await loadNumber; 128 | 129 | assert.strictEqual(loadNumber.state, "RESOLVED"); 130 | assert.strictEqual(loadNumber.isPending, false); 131 | assert.strictEqual(loadNumber.isResolved, true); 132 | assert.strictEqual(loadNumber.isRejected, false); 133 | assert.strictEqual(loadNumber.value, 5); 134 | assert.strictEqual(loadNumber.error, null); 135 | }); 136 | 137 | test("string", async function () { 138 | const loadString = new SignalAsyncData("js"); 139 | await loadString; 140 | 141 | // loadString 142 | assert.strictEqual(loadString.state, "RESOLVED"); 143 | assert.strictEqual(loadString.isPending, false); 144 | assert.strictEqual(loadString.isResolved, true); 145 | assert.strictEqual(loadString.isRejected, false); 146 | assert.strictEqual(loadString.value, "js"); 147 | assert.strictEqual(loadString.error, null); 148 | }); 149 | }); 150 | 151 | test("it returns error state", async function () { 152 | // This handles the error throw from rendering a rejected promise 153 | const deferred = defer(); 154 | const result = new SignalAsyncData(deferred.promise); 155 | 156 | // eslint-disable-next-line ember/no-array-prototype-extensions 157 | deferred.reject(new Error("foobar")); 158 | await deferred.promise.catch((error) => { 159 | assert.strictEqual(error instanceof Error, true); 160 | assert.strictEqual(error.message, "foobar", "thrown promise rejection"); 161 | }); 162 | 163 | assert.strictEqual(result.state, "REJECTED"); 164 | assert.strictEqual(result.isPending, false); 165 | assert.strictEqual(result.isResolved, false); 166 | assert.strictEqual(result.isRejected, true); 167 | assert.strictEqual(result.value, null); 168 | assert.strictEqual((result.error as Error).message, "foobar"); 169 | }); 170 | 171 | test("it returns loading state and then loaded state", async function () { 172 | const deferred = defer(); 173 | const result = new SignalAsyncData(deferred.promise); 174 | assert.strictEqual(result.state, "PENDING"); 175 | 176 | deferred.resolve(); 177 | await deferred.promise; 178 | 179 | assert.strictEqual(result.state, "RESOLVED"); 180 | }); 181 | 182 | test("it returns loading state and then error state", async function () { 183 | const deferred = defer(); 184 | const result = new SignalAsyncData(deferred.promise); 185 | assert.strictEqual(result.state, "PENDING"); 186 | 187 | // eslint-disable-next-line ember/no-array-prototype-extensions 188 | deferred.reject(new Error("foobar")); 189 | await deferred.promise.catch((err: Error) => { 190 | assert.strictEqual(err instanceof Error, true); 191 | assert.strictEqual(err.message, "foobar"); 192 | }); 193 | 194 | assert.strictEqual(result.state, "REJECTED"); 195 | }); 196 | 197 | test("it returns loaded state for already-resolved promises", async function () { 198 | const promise = Promise.resolve("hello"); 199 | const result = new SignalAsyncData(promise); 200 | await promise; 201 | assert.strictEqual(result.state, "RESOLVED"); 202 | }); 203 | 204 | test("it returns error state for already-rejected promises", async function () { 205 | const promise = Promise.reject(new Error("foobar")); 206 | const result = new SignalAsyncData(promise); 207 | 208 | // This handles the error thrown *locally*. 209 | await promise.catch((error: Error) => { 210 | assert.strictEqual(error instanceof Error, true); 211 | assert.strictEqual(error.message, "foobar"); 212 | }); 213 | 214 | assert.strictEqual(result.state, "REJECTED"); 215 | }); 216 | }); 217 | -------------------------------------------------------------------------------- /tests/async-function.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, assert } from "vitest"; 2 | import { assertStable, waitFor } from "./helpers.ts"; 3 | import { Signal } from "signal-polyfill"; 4 | import { State, signalFunction } from "../src/async-function.ts"; 5 | 6 | describe("signalFunction", () => { 7 | describe("State", () => { 8 | test("initial state", async function () { 9 | let m = ""; 10 | let resolve: (value?: unknown) => void; 11 | 12 | const promise = new Promise((r) => { 13 | resolve = r; 14 | }); 15 | 16 | const state = new State(() => promise); 17 | const promise2 = state.retry(); 18 | 19 | m = "isResolved"; 20 | assert.strictEqual(state.isResolved, false, m); 21 | 22 | m = "isRejected"; 23 | assert.strictEqual(state.isRejected, false, m); 24 | 25 | m = "error"; 26 | assert.strictEqual(state.error, null, m); 27 | 28 | m = "value"; 29 | assert.strictEqual(state.value, null, m); 30 | 31 | m = "isPending"; 32 | assert.strictEqual(state.isPending, true, m); 33 | 34 | // @ts-ignore This is normal promise usage 35 | resolve(); 36 | await promise2; 37 | }); 38 | 39 | test("successful state", async function () { 40 | let m = ""; 41 | let resolve: (value?: unknown) => void; 42 | 43 | const promise = new Promise((r) => { 44 | resolve = r; 45 | }); 46 | 47 | const value = Symbol("resolved value"); 48 | 49 | const state = new State(() => promise); 50 | const promise2 = state.retry(); 51 | 52 | // @ts-ignore This is normal promise usage 53 | resolve(value); 54 | await promise2; 55 | 56 | m = "isResolved"; 57 | assert.strictEqual(state.isResolved, true, m); 58 | 59 | m = "isRejected"; 60 | assert.strictEqual(state.isRejected, false, m); 61 | 62 | m = "error"; 63 | assert.strictEqual(state.error, null, m); 64 | 65 | m = "value"; 66 | assert.strictEqual(state.value, value, m); 67 | 68 | m = "isPending"; 69 | assert.strictEqual(state.isPending, false, m); 70 | }); 71 | 72 | test("error state", async function () { 73 | let m = ""; 74 | let reject: (value?: unknown) => void; 75 | const error = new Error("Denied!"); 76 | 77 | const promise = new Promise((_r, r) => { 78 | reject = r; 79 | }); 80 | 81 | const state = new State(() => promise); 82 | const promise2 = state.retry(); 83 | 84 | // @ts-ignore This is normal promise usage 85 | reject(error); 86 | 87 | // Avoid a test failure on uncaught promise 88 | try { 89 | await promise2; 90 | } catch (e) { 91 | if (e !== error) throw e; 92 | } 93 | 94 | m = "isResolved"; 95 | assert.strictEqual(state.isResolved, false, m); 96 | 97 | m = "isRejected"; 98 | assert.strictEqual(state.isRejected, true, m); 99 | 100 | m = "error"; 101 | assert.strictEqual(state.error, error, m); 102 | 103 | m = "value"; 104 | assert.strictEqual(state.value, null, m); 105 | 106 | m = "isPending"; 107 | assert.strictEqual(state.isPending, false, m); 108 | }); 109 | }); 110 | 111 | test("lifecycle", async function () { 112 | let runCount = 0; 113 | let steps: string[] = []; 114 | 115 | const countSignal = new Signal.State(1); 116 | const asyncState = signalFunction(async () => { 117 | let count = countSignal.get(); 118 | 119 | runCount++; 120 | // Pretend we're doing async work 121 | await Promise.resolve(); 122 | 123 | steps.push(`run ${runCount}, value: ${count}`); 124 | }); 125 | 126 | assert.strictEqual(asyncState.value, null); 127 | assertStable(() => asyncState.value); 128 | 129 | await waitFor(() => asyncState.promise); 130 | assertStable(() => asyncState.value); 131 | 132 | countSignal.set(2); 133 | await waitFor(() => asyncState.promise); 134 | assertStable(() => asyncState.value); 135 | 136 | countSignal.set(6); 137 | await waitFor(() => asyncState.promise); 138 | assertStable(() => asyncState.value); 139 | 140 | assert.deepEqual(steps, [ 141 | "run 1, value: 1", 142 | "run 2, value: 2", 143 | "run 3, value: 6", 144 | ]); 145 | }); 146 | 147 | test("it works with sync functions", async function () { 148 | const countSignal = new Signal.State(1); 149 | const asyncState = signalFunction(() => { 150 | let count = countSignal.get(); 151 | 152 | return count * 2; 153 | }); 154 | 155 | assert.strictEqual(asyncState.value, null); 156 | await asyncState.promise; 157 | 158 | assert.strictEqual(asyncState.value, 2); 159 | assertStable(() => asyncState.value); 160 | 161 | countSignal.set(2); 162 | await waitFor(() => asyncState.promise); 163 | 164 | assert.strictEqual(asyncState.value, 4); 165 | assertStable(() => asyncState.value); 166 | 167 | countSignal.set(6); 168 | await waitFor(() => asyncState.promise); 169 | 170 | assert.strictEqual(asyncState.value, 12); 171 | assertStable(() => asyncState.value); 172 | 173 | countSignal.set(7); 174 | await waitFor(() => asyncState.promise); 175 | 176 | assert.strictEqual(asyncState.value, 14); 177 | assertStable(() => asyncState.value); 178 | }); 179 | 180 | test("it works with async functions", async function () { 181 | let runCount = 0; 182 | const countSignal = new Signal.State(1); 183 | const asyncState = signalFunction(async () => { 184 | runCount++; 185 | let count = countSignal.get(); 186 | // Pretend we're doing async work 187 | await Promise.resolve(); 188 | 189 | return count * 2; 190 | }); 191 | 192 | assert.strictEqual(runCount, 0); 193 | assert.strictEqual(asyncState.value, null); 194 | assertStable(() => asyncState.value); 195 | assert.strictEqual(runCount, 1); 196 | 197 | assert.isTrue(asyncState.isLoading); 198 | await waitFor(() => asyncState.promise); 199 | assert.isFalse(asyncState.isLoading); 200 | 201 | assert.strictEqual(asyncState.value, 2); 202 | assertStable(() => asyncState.value); 203 | assert.strictEqual(runCount, 1); 204 | 205 | countSignal.set(2); 206 | await waitFor(() => asyncState.promise); 207 | assert.strictEqual(asyncState.value, 4); 208 | assertStable(() => asyncState.value); 209 | assert.strictEqual(runCount, 2); 210 | 211 | countSignal.set(6); 212 | await waitFor(() => asyncState.promise); 213 | 214 | assert.strictEqual(asyncState.value, 12); 215 | assertStable(() => asyncState.value); 216 | assert.strictEqual(runCount, 3); 217 | }); 218 | }); 219 | -------------------------------------------------------------------------------- /tests/deep.test.ts: -------------------------------------------------------------------------------- 1 | import { signal } from "../src/index.ts"; 2 | import { guard } from "./helpers"; 3 | import { describe, test, assert } from "vitest"; 4 | import { deepSignal, deep } from "../src/deep.ts"; 5 | import { assertReactivelySettled } from "./helpers.ts"; 6 | 7 | /** 8 | * How do you type deep objects? is it possible? 9 | */ 10 | describe("deep", () => { 11 | test("object access", () => { 12 | let reactive = deep({}) as any; 13 | 14 | assert.notOk(reactive.obj?.foo?.bar); 15 | 16 | reactive.obj = { foo: { bar: 2 } }; 17 | 18 | assert.strictEqual(reactive.obj?.foo?.bar, 2); 19 | 20 | assertReactivelySettled({ 21 | access: () => reactive.foo, 22 | change: () => (reactive.foo += 2), 23 | }); 24 | }); 25 | 26 | test("array access", () => { 27 | let reactive = deep([]) as any; 28 | 29 | assert.notOk(reactive[0]); 30 | 31 | reactive[0] = true; 32 | 33 | assert.strictEqual(reactive[0], true); 34 | 35 | assertReactivelySettled({ 36 | access: () => reactive[1], 37 | change: () => (reactive[1] += 2), 38 | }); 39 | }); 40 | 41 | describe("unproxyable", () => { 42 | let values = [undefined, null, true, false, 1, "", NaN, "foo"]; 43 | 44 | for (let value of values) { 45 | test(`'${value}' stays '${value}'`, () => { 46 | let reactive = deep(value); 47 | 48 | if (Number.isNaN(value)) { 49 | assert.ok(Number.isNaN(reactive)); 50 | } else { 51 | assert.strictEqual(reactive, value); 52 | } 53 | }); 54 | } 55 | }); 56 | 57 | test("multiple assignments", () => { 58 | let reactive = deep({}) as any; 59 | 60 | assert.strictEqual(reactive.obj, undefined); 61 | assert.strictEqual(reactive.obj?.bar, undefined); 62 | assert.strictEqual( 63 | reactive.obj, 64 | undefined, 65 | `accessing deep values does not create objects`, 66 | ); 67 | 68 | // Deep setting should be allowed? 69 | reactive.obj = {}; 70 | reactive.obj.bar = 2; 71 | assert.strictEqual(reactive.obj?.bar, 2); 72 | assert.deepEqual(reactive.obj, { bar: 2 }); 73 | }); 74 | }); 75 | 76 | describe("deepSignal", function () { 77 | describe("Objects", function () { 78 | test("object access", async function () { 79 | class Foo { 80 | @deepSignal accessor obj = {} as any; 81 | 82 | @signal 83 | get objDeep() { 84 | return this.obj.foo?.bar; 85 | } 86 | } 87 | 88 | let instance = new Foo(); 89 | 90 | assert.notOk(instance.objDeep); 91 | 92 | instance.obj.foo = { bar: 3 }; 93 | assert.strictEqual(instance.objDeep, 3); 94 | 95 | instance.obj.foo = { bar: 4 }; 96 | assert.strictEqual(instance.objDeep, 4); 97 | 98 | instance.obj = { foo: { bar: 5 } }; 99 | assert.strictEqual(instance.objDeep, 5); 100 | 101 | instance.obj.foo = { bar: 4 }; 102 | assert.strictEqual(instance.objDeep, 4); 103 | }); 104 | 105 | test("object access in an array", async function () { 106 | class Foo { 107 | @deepSignal accessor arr: any[] = []; 108 | 109 | @signal 110 | get arrDeep() { 111 | return this.arr[0]?.foo?.bar; 112 | } 113 | } 114 | 115 | let instance = new Foo(); 116 | 117 | assert.notOk(instance.arrDeep); 118 | 119 | instance.arr.push({ foo: { bar: 2 } }); 120 | 121 | assert.strictEqual(instance.arrDeep, 2); 122 | }); 123 | 124 | test("undefined to object", async function () { 125 | class Foo { 126 | @deepSignal accessor obj: Record | undefined = undefined; 127 | } 128 | 129 | let instance = new Foo(); 130 | 131 | assert.strictEqual(instance.obj, null); 132 | 133 | instance.obj = {}; 134 | 135 | assert.deepEqual(instance.obj, {}); 136 | }); 137 | 138 | test("null to object", async function () { 139 | class Foo { 140 | @deepSignal accessor obj: Record | null = null; 141 | } 142 | 143 | let instance = new Foo(); 144 | 145 | assert.strictEqual(instance.obj, null); 146 | 147 | instance.obj = {}; 148 | 149 | assert.deepEqual(instance.obj, {}); 150 | }); 151 | }); 152 | 153 | describe("Arrays", function () { 154 | describe("#splice", function () { 155 | test("it works", async function () { 156 | class Foo { 157 | @deepSignal accessor arr: any[] = [0, 1, 3]; 158 | 159 | @signal 160 | get arrDeep() { 161 | return this.arr[0]?.foo?.bar; 162 | } 163 | } 164 | 165 | let instance = new Foo(); 166 | 167 | instance.arr.splice(1, 1); 168 | 169 | assert.deepEqual(instance.arr, [0, 3]); 170 | }); 171 | 172 | test("it works on deeply nested arrays", async function () { 173 | class Foo { 174 | @deepSignal accessor obj = { children: [{ property: [0, 1, 3] }] }; 175 | 176 | splice = () => { 177 | guard( 178 | `Test failed to define an array on obj.children`, 179 | this.obj.children[0], 180 | ); 181 | 182 | return this.obj.children[0].property.splice(1, 1); 183 | }; 184 | 185 | @signal 186 | get output() { 187 | guard( 188 | `Test failed to define an array on obj.children`, 189 | this.obj.children[0], 190 | ); 191 | 192 | return this.obj.children[0].property; 193 | } 194 | } 195 | 196 | let instance = new Foo(); 197 | 198 | assert.deepEqual(instance.output, [0, 1, 3]); 199 | instance.splice(); 200 | assert.deepEqual(instance.output, [0, 3]); 201 | }); 202 | }); 203 | 204 | test("#indexOf works", async function () { 205 | class Foo { 206 | @deepSignal accessor arr = [] as any; 207 | 208 | get item1() { 209 | return arr[0]; 210 | } 211 | } 212 | 213 | let instance = new Foo(); 214 | 215 | const item1 = { bar: "baz" }; 216 | const item2 = { qux: "norf" }; 217 | 218 | instance.arr.push(item1); 219 | instance.arr.push(item2); 220 | 221 | let arr = instance.arr; 222 | let first = arr.indexOf(instance.item1); 223 | let second = arr.indexOf(item2); 224 | 225 | assert.strictEqual(first, 0); 226 | assert.strictEqual(second, 1); 227 | }); 228 | 229 | test("#indexOf works multiple times", async function () { 230 | class Foo { 231 | @deepSignal accessor arr = [] as any; 232 | } 233 | 234 | let instance = new Foo(); 235 | 236 | const item = { bar: "baz" }; 237 | 238 | instance.arr.push(item); 239 | 240 | let arr = instance.arr; 241 | let first = arr.indexOf(item); 242 | let second = arr.indexOf(item); 243 | 244 | assert.strictEqual(first, 0); 245 | assert.strictEqual(second, 0); 246 | }); 247 | }); 248 | 249 | test("array data can be re-set", async function () { 250 | class Foo { 251 | @deepSignal accessor arr: any[] = [0, 1, 3]; 252 | 253 | @signal 254 | get arrDeep() { 255 | return this.arr[0]?.foo?.bar; 256 | } 257 | } 258 | 259 | let instance = new Foo(); 260 | 261 | instance.arr = [4, 8]; 262 | 263 | assert.deepEqual(instance.arr, [4, 8]); 264 | }); 265 | 266 | test("array data can be immutably treated", async function () { 267 | class Foo { 268 | @deepSignal accessor arr: { id: number; prop: string }[] = [ 269 | { 270 | id: 1, 271 | prop: "foo", 272 | }, 273 | { 274 | id: 2, 275 | prop: "bar", 276 | }, 277 | { 278 | id: 3, 279 | prop: "baz", 280 | }, 281 | ]; 282 | } 283 | 284 | let instance = new Foo(); 285 | 286 | assert.deepEqual(instance.arr, [ 287 | { 288 | id: 1, 289 | prop: "foo", 290 | }, 291 | { 292 | id: 2, 293 | prop: "bar", 294 | }, 295 | { 296 | id: 3, 297 | prop: "baz", 298 | }, 299 | ]); 300 | 301 | instance.arr = instance.arr.map((el) => { 302 | if (el.id === 2) { 303 | return { 304 | ...el, 305 | prop: "boink", 306 | }; 307 | } 308 | 309 | return el; 310 | }); 311 | 312 | assert.deepEqual(instance.arr, [ 313 | { 314 | id: 1, 315 | prop: "foo", 316 | }, 317 | { 318 | id: 2, 319 | prop: "boink", 320 | }, 321 | { 322 | id: 3, 323 | prop: "baz", 324 | }, 325 | ]); 326 | }); 327 | }); 328 | -------------------------------------------------------------------------------- /tests/helpers.ts: -------------------------------------------------------------------------------- 1 | import { assert, test } from "vitest"; 2 | import { Signal } from "signal-polyfill"; 3 | 4 | class GuardError extends Error {} 5 | 6 | export function guard(msg: string, test: unknown): asserts test { 7 | if (!test) { 8 | throw new GuardError(msg); 9 | } 10 | } 11 | 12 | export function assertStable(access: () => unknown) { 13 | let calls = 0; 14 | 15 | const computed = new Signal.Computed(() => { 16 | calls++; 17 | return access(); 18 | }); 19 | 20 | computed.get(); 21 | assert.equal(calls, 1); 22 | computed.get(); 23 | assert.equal(calls, 1); 24 | } 25 | 26 | export function assertReactivelySettled(options: { 27 | access: () => unknown; 28 | change: () => unknown; 29 | shouldUpdate?: boolean; 30 | }) { 31 | let { access, change, shouldUpdate } = options; 32 | 33 | shouldUpdate ??= true; 34 | 35 | let calls = 0; 36 | 37 | const computed = new Signal.Computed(() => { 38 | calls++; 39 | return access(); 40 | }); 41 | 42 | computed.get(); 43 | assert.equal(calls, 1, "Only one evaluation is made"); 44 | computed.get(); 45 | assert.equal( 46 | calls, 47 | 1, 48 | "Only one evaluation is made, even after repeat get() call", 49 | ); 50 | 51 | change(); 52 | 53 | if (shouldUpdate) { 54 | computed.get(); 55 | assert.equal(calls, 2, "After a change, a second evaluation is made"); 56 | computed.get(); 57 | assert.equal( 58 | calls, 59 | 2, 60 | "No additional evaluation is made after repeat get() call", 61 | ); 62 | return; 63 | } 64 | 65 | computed.get(); 66 | assert.equal( 67 | calls, 68 | 1, 69 | "After an unrelated change, a second evaluation is not made", 70 | ); 71 | } 72 | 73 | export function waitForAnimationFrame() { 74 | return new Promise((resolve) => { 75 | requestAnimationFrame(resolve); 76 | }); 77 | } 78 | 79 | export function waitForMicrotask() { 80 | return new Promise((resolve) => { 81 | queueMicrotask(() => resolve(null)); 82 | }); 83 | } 84 | 85 | export function waitFor(fn: () => unknown) { 86 | let waiter = new Promise((resolve) => { 87 | let interval = setInterval(() => { 88 | let result = fn() as Promise; 89 | if (result) { 90 | (async () => { 91 | await result; 92 | clearInterval(interval); 93 | resolve(result); 94 | })(); 95 | } 96 | }, 5); 97 | }); 98 | 99 | let timeout = new Promise((resolve) => setTimeout(resolve, 1000)); 100 | 101 | return Promise.race([waiter, timeout]); 102 | } 103 | 104 | interface Deferred { 105 | resolve: (value?: unknown) => void; 106 | reject: (value?: unknown) => void; 107 | promise: Promise; 108 | } 109 | 110 | export function defer(): Deferred { 111 | const deferred = {} as Partial; 112 | 113 | deferred.promise = new Promise((resolve, reject) => { 114 | deferred.resolve = resolve; 115 | deferred.reject = reject; 116 | }); 117 | 118 | return deferred as Deferred; 119 | } 120 | 121 | export function reactivityTest( 122 | name: string, 123 | State: new () => { value: unknown; update: () => void }, 124 | shouldUpdate = true, 125 | ) { 126 | return test(name, () => { 127 | let state = new State(); 128 | 129 | assertReactivelySettled({ 130 | access: () => state.value, 131 | change: () => state.update(), 132 | shouldUpdate, 133 | }); 134 | }); 135 | } 136 | -------------------------------------------------------------------------------- /tests/local-copy.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, assert } from "vitest"; 2 | import { localCopy } from "../src/local-copy.ts"; 3 | import { Signal } from "signal-polyfill"; 4 | import { assertReactivelySettled, assertStable } from "./helpers.ts"; 5 | 6 | describe("localCopy()", () => { 7 | test("it works as a Signal", () => { 8 | let remote = new Signal.State(123); 9 | 10 | let local = localCopy(() => remote.get()); 11 | 12 | assert.strictEqual(local.get(), 123, "defaults to the remote value"); 13 | 14 | assertReactivelySettled({ 15 | access: () => local.get(), 16 | change: () => local.set(456), 17 | }); 18 | 19 | assert.strictEqual(local.get(), 456, "local value updates correctly"); 20 | assert.strictEqual(remote.get(), 123, "remote value does not update"); 21 | 22 | remote.set(789); 23 | 24 | assert.strictEqual( 25 | local.get(), 26 | 789, 27 | "local value updates to new remote value", 28 | ); 29 | assert.strictEqual(remote.get(), 789, "remote value is updated"); 30 | 31 | assertStable(() => local.get()); 32 | }); 33 | 34 | test("it works as a Signal in a class", () => { 35 | class State { 36 | remote = new Signal.State(123); 37 | local = localCopy(() => this.remote.get()); 38 | } 39 | 40 | let state = new State(); 41 | 42 | assert.strictEqual(state.local.get(), 123, "defaults to the remote value"); 43 | 44 | assertReactivelySettled({ 45 | access: () => state.local.get(), 46 | change: () => state.local.set(456), 47 | }); 48 | 49 | assert.strictEqual(state.local.get(), 456, "local value updates correctly"); 50 | assert.strictEqual(state.remote.get(), 123, "remote value does not update"); 51 | 52 | state.remote.set(789); 53 | 54 | assert.strictEqual( 55 | state.local.get(), 56 | 789, 57 | "local value updates to new remote value", 58 | ); 59 | assert.strictEqual(state.remote.get(), 789, "remote value is updated"); 60 | 61 | assertStable(() => state.local.get()); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /tests/map.test.ts: -------------------------------------------------------------------------------- 1 | import { SignalMap } from "../src/map.ts"; 2 | import { describe, test, assert } from "vitest"; 3 | import { expectTypeOf } from "expect-type"; 4 | 5 | import { reactivityTest } from "./helpers.ts"; 6 | 7 | expectTypeOf>().toMatchTypeOf>(); 8 | expectTypeOf>().not.toMatchTypeOf< 9 | SignalMap 10 | >(); 11 | 12 | describe("SignalMap", function () { 13 | test("constructor", () => { 14 | const map = new SignalMap([["foo", 123]]); 15 | 16 | assert.equal(map.get("foo"), 123); 17 | assert.equal(map.size, 1); 18 | assert.ok(map instanceof Map); 19 | }); 20 | 21 | test("works with all kinds of keys", () => { 22 | const map = new SignalMap([ 23 | ["foo", 123], 24 | [{}, {}], 25 | [ 26 | () => { 27 | /* no op! */ 28 | }, 29 | "bar", 30 | ], 31 | [123, true], 32 | [true, false], 33 | [null, null], 34 | ]); 35 | 36 | assert.equal(map.size, 6); 37 | }); 38 | 39 | test("get/set", () => { 40 | const map = new SignalMap(); 41 | 42 | map.set("foo", 123); 43 | assert.equal(map.get("foo"), 123); 44 | 45 | map.set("foo", 456); 46 | assert.equal(map.get("foo"), 456); 47 | }); 48 | 49 | test("has", () => { 50 | const map = new SignalMap(); 51 | 52 | assert.equal(map.has("foo"), false); 53 | map.set("foo", 123); 54 | assert.equal(map.has("foo"), true); 55 | }); 56 | 57 | test("entries", () => { 58 | const map = new SignalMap(); 59 | map.set(0, 1); 60 | map.set(1, 2); 61 | map.set(2, 3); 62 | 63 | const iter = map.entries(); 64 | 65 | assert.deepEqual(iter.next().value, [0, 1]); 66 | assert.deepEqual(iter.next().value, [1, 2]); 67 | assert.deepEqual(iter.next().value, [2, 3]); 68 | assert.equal(iter.next().done, true); 69 | }); 70 | 71 | test("keys", () => { 72 | const map = new SignalMap(); 73 | map.set(0, 1); 74 | map.set(1, 2); 75 | map.set(2, 3); 76 | 77 | const iter = map.keys(); 78 | 79 | assert.equal(iter.next().value, 0); 80 | assert.equal(iter.next().value, 1); 81 | assert.equal(iter.next().value, 2); 82 | assert.equal(iter.next().done, true); 83 | }); 84 | 85 | test("values", () => { 86 | const map = new SignalMap(); 87 | map.set(0, 1); 88 | map.set(1, 2); 89 | map.set(2, 3); 90 | 91 | const iter = map.values(); 92 | 93 | assert.equal(iter.next().value, 1); 94 | assert.equal(iter.next().value, 2); 95 | assert.equal(iter.next().value, 3); 96 | assert.equal(iter.next().done, true); 97 | }); 98 | 99 | test("forEach", () => { 100 | const map = new SignalMap(); 101 | map.set(0, 1); 102 | map.set(1, 2); 103 | map.set(2, 3); 104 | 105 | let count = 0; 106 | let values = ""; 107 | 108 | map.forEach((v, k) => { 109 | count++; 110 | values += k; 111 | values += v; 112 | }); 113 | 114 | assert.equal(count, 3); 115 | assert.equal(values, "011223"); 116 | }); 117 | 118 | test("size", () => { 119 | const map = new SignalMap(); 120 | assert.equal(map.size, 0); 121 | 122 | map.set(0, 1); 123 | assert.equal(map.size, 1); 124 | 125 | map.set(1, 2); 126 | assert.equal(map.size, 2); 127 | 128 | map.delete(1); 129 | assert.equal(map.size, 1); 130 | 131 | map.set(0, 3); 132 | assert.equal(map.size, 1); 133 | }); 134 | 135 | test("delete", () => { 136 | const map = new SignalMap(); 137 | 138 | assert.equal(map.has(0), false); 139 | 140 | map.set(0, 123); 141 | assert.equal(map.has(0), true); 142 | 143 | map.delete(0); 144 | assert.equal(map.has(0), false); 145 | }); 146 | 147 | test("clear", () => { 148 | const map = new SignalMap(); 149 | 150 | map.set(0, 1); 151 | map.set(1, 2); 152 | assert.equal(map.size, 2); 153 | 154 | map.clear(); 155 | assert.equal(map.size, 0); 156 | assert.equal(map.get(0), undefined); 157 | assert.equal(map.get(1), undefined); 158 | }); 159 | 160 | reactivityTest( 161 | "get/set", 162 | class { 163 | map = new SignalMap(); 164 | 165 | get value() { 166 | return this.map.get("foo"); 167 | } 168 | 169 | update() { 170 | this.map.set("foo", 123); 171 | } 172 | }, 173 | ); 174 | 175 | reactivityTest( 176 | "get/set existing value", 177 | class { 178 | map = new SignalMap([["foo", 456]]); 179 | 180 | get value() { 181 | return this.map.get("foo"); 182 | } 183 | 184 | update() { 185 | this.map.set("foo", 123); 186 | } 187 | }, 188 | ); 189 | 190 | reactivityTest( 191 | "get/set unrelated value", 192 | class { 193 | map = new SignalMap([["foo", 456]]); 194 | 195 | get value() { 196 | return this.map.get("foo"); 197 | } 198 | 199 | update() { 200 | this.map.set("bar", 123); 201 | } 202 | }, 203 | false, 204 | ); 205 | 206 | reactivityTest( 207 | "has", 208 | class { 209 | map = new SignalMap(); 210 | 211 | get value() { 212 | return this.map.has("foo"); 213 | } 214 | 215 | update() { 216 | this.map.set("foo", 123); 217 | } 218 | }, 219 | ); 220 | 221 | reactivityTest( 222 | "entries", 223 | class { 224 | map = new SignalMap(); 225 | 226 | get value() { 227 | return this.map.entries(); 228 | } 229 | 230 | update() { 231 | this.map.set("foo", 123); 232 | } 233 | }, 234 | ); 235 | 236 | reactivityTest( 237 | "keys", 238 | class { 239 | map = new SignalMap(); 240 | 241 | get value() { 242 | return this.map.keys(); 243 | } 244 | 245 | update() { 246 | this.map.set("foo", 123); 247 | } 248 | }, 249 | ); 250 | 251 | reactivityTest( 252 | "values", 253 | class { 254 | map = new SignalMap(); 255 | 256 | get value() { 257 | return this.map.values(); 258 | } 259 | 260 | update() { 261 | this.map.set("foo", 123); 262 | } 263 | }, 264 | ); 265 | 266 | reactivityTest( 267 | "forEach", 268 | class { 269 | map = new SignalMap(); 270 | 271 | get value() { 272 | this.map.forEach(() => { 273 | /* no op! */ 274 | }); 275 | return "test"; 276 | } 277 | 278 | update() { 279 | this.map.set("foo", 123); 280 | } 281 | }, 282 | ); 283 | 284 | reactivityTest( 285 | "size", 286 | class { 287 | map = new SignalMap(); 288 | 289 | get value() { 290 | return this.map.size; 291 | } 292 | 293 | update() { 294 | this.map.set("foo", 123); 295 | } 296 | }, 297 | ); 298 | 299 | reactivityTest( 300 | "delete", 301 | class { 302 | map = new SignalMap([["foo", 123]]); 303 | 304 | get value() { 305 | return this.map.get("foo"); 306 | } 307 | 308 | update() { 309 | this.map.delete("foo"); 310 | } 311 | }, 312 | ); 313 | 314 | reactivityTest( 315 | "delete unrelated value", 316 | class { 317 | map = new SignalMap([ 318 | ["foo", 123], 319 | ["bar", 456], 320 | ]); 321 | 322 | get value() { 323 | return this.map.get("foo"); 324 | } 325 | 326 | update() { 327 | this.map.delete("bar"); 328 | } 329 | }, 330 | false, 331 | ); 332 | 333 | reactivityTest( 334 | "clear", 335 | class { 336 | map = new SignalMap([["foo", 123]]); 337 | 338 | get value() { 339 | return this.map.get("foo"); 340 | } 341 | 342 | update() { 343 | this.map.clear(); 344 | } 345 | }, 346 | ); 347 | }); 348 | -------------------------------------------------------------------------------- /tests/object.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, assert } from "vitest"; 2 | import { SignalObject } from "../src/object"; 3 | import { expectTypeOf } from "expect-type"; 4 | import { assertReactivelySettled } from "./helpers.ts"; 5 | 6 | describe("SignalObject", () => { 7 | it("works", () => { 8 | let original = { foo: 123 }; 9 | let obj = new SignalObject(original); 10 | 11 | assert.ok(obj instanceof SignalObject); 12 | expectTypeOf(obj).toEqualTypeOf<{ foo: number }>(); 13 | assert.deepEqual(Object.keys(obj), ["foo"]); 14 | assert.equal(obj.foo, 123); 15 | 16 | obj.foo = 456; 17 | assert.equal(obj.foo, 456, "object updated correctly"); 18 | assert.equal(original.foo, 123, "original object was not updated"); 19 | 20 | assertReactivelySettled({ 21 | access: () => obj.foo, 22 | change: () => (obj.foo += 2), 23 | }); 24 | }); 25 | 26 | it("preserves getters", () => { 27 | let obj = new SignalObject({ 28 | foo: 123, 29 | get bar(): number { 30 | return this.foo; 31 | }, 32 | }); 33 | 34 | expectTypeOf(obj).toEqualTypeOf<{ foo: number; readonly bar: number }>(); 35 | 36 | obj.foo = 456; 37 | assert.equal(obj.foo, 456, "object updated correctly"); 38 | assert.equal(obj.bar, 456, "getter cloned correctly"); 39 | 40 | assertReactivelySettled({ 41 | access: () => obj.bar, 42 | change: () => { 43 | obj.foo += 2; 44 | 45 | assert.equal(obj.foo, 458, "foo is updated"); 46 | assert.equal(obj.bar, 458, "bar is updated"); 47 | }, 48 | }); 49 | }); 50 | 51 | it("works with methods", () => { 52 | let obj = new SignalObject({ 53 | foo: 123, 54 | 55 | method() { 56 | return this.foo; 57 | }, 58 | }); 59 | 60 | expectTypeOf(obj).toEqualTypeOf<{ foo: number; method: () => number }>(); 61 | 62 | assertReactivelySettled({ 63 | access: () => obj.method(), 64 | change: () => { 65 | obj.foo += 2; 66 | }, 67 | }); 68 | }); 69 | 70 | it("fromEntries", () => { 71 | const entries = Object.entries({ foo: 123 }); 72 | let obj = SignalObject.fromEntries(entries); 73 | // We will lose the specific key, becuase `Object.entries` does not preserve 74 | // it, but the type produced by `TrackedObject.fromEntries` should match the 75 | // type produced by `Object.fromEntries`. 76 | let underlying = Object.fromEntries(entries); 77 | expectTypeOf(obj).toEqualTypeOf(underlying); 78 | 79 | assert.ok(obj instanceof SignalObject); 80 | assert.deepEqual(Object.keys(obj), ["foo"]); 81 | 82 | assertReactivelySettled({ 83 | access: () => obj["foo"], 84 | change: () => { 85 | obj["foo"] += 2; 86 | }, 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /tests/set.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, assert } from "vitest"; 2 | 3 | import { SignalSet } from "../src/set.ts"; 4 | 5 | import { expectTypeOf } from "expect-type"; 6 | 7 | import { reactivityTest } from "./helpers.ts"; 8 | 9 | expectTypeOf>().toMatchTypeOf>(); 10 | expectTypeOf>().not.toEqualTypeOf>(); 11 | 12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 13 | type AnyFn = (...args: any[]) => any; 14 | 15 | describe("SignalSet", function () { 16 | test("constructor", () => { 17 | const set = new SignalSet(["foo", 123]); 18 | 19 | assert.equal(set.has("foo"), true); 20 | assert.equal(set.size, 2); 21 | assert.ok(set instanceof Set); 22 | 23 | const setFromSet = new SignalSet(set); 24 | assert.equal(setFromSet.has("foo"), true); 25 | assert.equal(setFromSet.size, 2); 26 | assert.ok(setFromSet instanceof Set); 27 | 28 | const setFromEmpty = new SignalSet(); 29 | assert.equal(setFromEmpty.has("anything"), false); 30 | assert.equal(setFromEmpty.size, 0); 31 | assert.ok(setFromEmpty instanceof Set); 32 | }); 33 | 34 | test("works with all kinds of values", () => { 35 | const set = new SignalSet< 36 | string | Record | AnyFn | number | boolean | null 37 | >([ 38 | "foo", 39 | {}, 40 | () => { 41 | /* no op */ 42 | }, 43 | 123, 44 | true, 45 | null, 46 | ]); 47 | 48 | assert.equal(set.size, 6); 49 | }); 50 | 51 | test("add/has", () => { 52 | const set = new SignalSet(); 53 | 54 | set.add("foo"); 55 | assert.equal(set.has("foo"), true); 56 | }); 57 | 58 | test("entries", () => { 59 | const set = new SignalSet(); 60 | set.add(0); 61 | set.add(2); 62 | set.add(1); 63 | 64 | const iter = set.entries(); 65 | 66 | assert.deepEqual(iter.next().value, [0, 0]); 67 | assert.deepEqual(iter.next().value, [2, 2]); 68 | assert.deepEqual(iter.next().value, [1, 1]); 69 | assert.equal(iter.next().done, true); 70 | }); 71 | 72 | test("keys", () => { 73 | const set = new SignalSet(); 74 | set.add(0); 75 | set.add(2); 76 | set.add(1); 77 | 78 | const iter = set.keys(); 79 | 80 | assert.equal(iter.next().value, 0); 81 | assert.equal(iter.next().value, 2); 82 | assert.equal(iter.next().value, 1); 83 | assert.equal(iter.next().done, true); 84 | }); 85 | 86 | test("values", () => { 87 | const set = new SignalSet(); 88 | set.add(0); 89 | set.add(2); 90 | set.add(1); 91 | 92 | const iter = set.values(); 93 | 94 | assert.equal(iter.next().value, 0); 95 | assert.equal(iter.next().value, 2); 96 | assert.equal(iter.next().value, 1); 97 | assert.equal(iter.next().done, true); 98 | }); 99 | 100 | test("forEach", () => { 101 | const set = new SignalSet(); 102 | set.add(0); 103 | set.add(1); 104 | set.add(2); 105 | 106 | let count = 0; 107 | let values = ""; 108 | 109 | set.forEach((v, k) => { 110 | count++; 111 | values += k; 112 | values += v; 113 | }); 114 | 115 | assert.equal(count, 3); 116 | assert.equal(values, "001122"); 117 | }); 118 | 119 | test("size", () => { 120 | const set = new SignalSet(); 121 | assert.equal(set.size, 0); 122 | 123 | set.add(0); 124 | assert.equal(set.size, 1); 125 | 126 | set.add(1); 127 | assert.equal(set.size, 2); 128 | 129 | set.delete(1); 130 | assert.equal(set.size, 1); 131 | 132 | set.add(0); 133 | assert.equal(set.size, 1); 134 | }); 135 | 136 | test("delete", () => { 137 | const set = new SignalSet(); 138 | 139 | assert.equal(set.has(0), false); 140 | 141 | set.add(0); 142 | assert.equal(set.has(0), true); 143 | 144 | set.delete(0); 145 | assert.equal(set.has(0), false); 146 | }); 147 | 148 | test("clear", () => { 149 | const set = new SignalSet(); 150 | 151 | set.add(0); 152 | set.add(1); 153 | assert.equal(set.size, 2); 154 | 155 | set.clear(); 156 | assert.equal(set.size, 0); 157 | assert.equal(set.has(0), false); 158 | assert.equal(set.has(1), false); 159 | }); 160 | 161 | reactivityTest( 162 | "add/has", 163 | class { 164 | set = new SignalSet(); 165 | 166 | get value() { 167 | return this.set.has("foo"); 168 | } 169 | 170 | update() { 171 | this.set.add("foo"); 172 | } 173 | }, 174 | ); 175 | 176 | reactivityTest( 177 | "add/has existing value", 178 | class { 179 | set = new SignalSet(["foo"]); 180 | 181 | get value() { 182 | return this.set.has("foo"); 183 | } 184 | 185 | update() { 186 | this.set.add("foo"); 187 | } 188 | }, 189 | ); 190 | 191 | reactivityTest( 192 | "add/has unrelated value", 193 | class { 194 | set = new SignalSet(); 195 | 196 | get value() { 197 | return this.set.has("foo"); 198 | } 199 | 200 | update() { 201 | this.set.add("bar"); 202 | } 203 | }, 204 | false, 205 | ); 206 | 207 | reactivityTest( 208 | "entries", 209 | class { 210 | set = new SignalSet(); 211 | 212 | get value() { 213 | return this.set.entries(); 214 | } 215 | 216 | update() { 217 | this.set.add("foo"); 218 | } 219 | }, 220 | ); 221 | 222 | reactivityTest( 223 | "keys", 224 | class { 225 | set = new SignalSet(); 226 | 227 | get value() { 228 | return this.set.keys(); 229 | } 230 | 231 | update() { 232 | this.set.add("foo"); 233 | } 234 | }, 235 | ); 236 | 237 | reactivityTest( 238 | "values", 239 | class { 240 | set = new SignalSet(); 241 | 242 | get value() { 243 | return this.set.values(); 244 | } 245 | 246 | update() { 247 | this.set.add("foo"); 248 | } 249 | }, 250 | ); 251 | 252 | reactivityTest( 253 | "forEach", 254 | class { 255 | set = new SignalSet(); 256 | 257 | get value() { 258 | this.set.forEach(() => { 259 | /* no-op */ 260 | }); 261 | return "test"; 262 | } 263 | 264 | update() { 265 | this.set.add("foo"); 266 | } 267 | }, 268 | ); 269 | 270 | reactivityTest( 271 | "size", 272 | class { 273 | set = new SignalSet(); 274 | 275 | get value() { 276 | return this.set.size; 277 | } 278 | 279 | update() { 280 | this.set.add("foo"); 281 | } 282 | }, 283 | ); 284 | 285 | reactivityTest( 286 | "delete", 287 | class { 288 | set = new SignalSet(["foo", 123]); 289 | 290 | get value() { 291 | return this.set.has("foo"); 292 | } 293 | 294 | update() { 295 | this.set.delete("foo"); 296 | } 297 | }, 298 | ); 299 | 300 | reactivityTest( 301 | "delete unrelated value", 302 | class { 303 | set = new SignalSet(["foo", 123]); 304 | 305 | get value() { 306 | return this.set.has("foo"); 307 | } 308 | 309 | update() { 310 | this.set.delete(123); 311 | } 312 | }, 313 | false, 314 | ); 315 | 316 | reactivityTest( 317 | "clear", 318 | class { 319 | set = new SignalSet(["foo", 123]); 320 | 321 | get value() { 322 | return this.set.has("foo"); 323 | } 324 | 325 | update() { 326 | this.set.clear(); 327 | } 328 | }, 329 | ); 330 | }); 331 | -------------------------------------------------------------------------------- /tests/subtle/batched-effect.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, assert } from "vitest"; 2 | import { Signal } from "signal-polyfill"; 3 | import { batchedEffect, batch } from "../../src/subtle/batched-effect.ts"; 4 | 5 | describe("batchedEffect()", () => { 6 | test("calls the effect function synchronously at the end of a batch", async () => { 7 | const a = new Signal.State(0); 8 | const b = new Signal.State(0); 9 | 10 | let callCount = 0; 11 | 12 | batchedEffect(() => { 13 | a.get(); 14 | b.get(); 15 | callCount++; 16 | }); 17 | 18 | // Effect callbacks are called immediately 19 | assert.strictEqual(callCount, 1); 20 | 21 | batch(() => { 22 | a.set(1); 23 | b.set(1); 24 | }); 25 | 26 | // Effect callbacks are batched and called sync 27 | assert.strictEqual(callCount, 2); 28 | 29 | batch(() => { 30 | a.set(2); 31 | }); 32 | assert.strictEqual(callCount, 3); 33 | 34 | await 0; 35 | 36 | // No lingering effect calls 37 | assert.strictEqual(callCount, 3); 38 | }); 39 | 40 | test("nested batches", async () => { 41 | const a = new Signal.State(0); 42 | const b = new Signal.State(0); 43 | const c = new Signal.State(0); 44 | 45 | let callCount = 0; 46 | 47 | batchedEffect(() => { 48 | a.get(); 49 | b.get(); 50 | c.get(); 51 | callCount++; 52 | }); 53 | 54 | batch(() => { 55 | a.set(1); 56 | batch(() => { 57 | b.set(1); 58 | }); 59 | c.set(1); 60 | }); 61 | 62 | // Effect callbacks are batched and called sync 63 | assert.strictEqual(callCount, 2); 64 | }); 65 | 66 | test("batch nested in an effect", async () => { 67 | const a = new Signal.State(0); 68 | const b = new Signal.State(0); 69 | 70 | let log: Array = []; 71 | 72 | batchedEffect(() => { 73 | log.push("A"); 74 | a.get(); 75 | batch(() => { 76 | b.set(a.get()); 77 | }); 78 | }); 79 | 80 | assert.deepEqual(log, ["A"]); 81 | 82 | batchedEffect(() => { 83 | log.push("B"); 84 | b.get(); 85 | }); 86 | 87 | assert.deepEqual(log, ["A", "B"]); 88 | log.length = 0; 89 | 90 | batch(() => { 91 | a.set(1); 92 | }); 93 | 94 | // Both effects should run 95 | assert.deepEqual(log, ["A", "B"]); 96 | }); 97 | 98 | test("calls the effect function asynchronously outside a batch", async () => { 99 | const a = new Signal.State(0); 100 | const b = new Signal.State(0); 101 | 102 | let callCount = 0; 103 | 104 | batchedEffect(() => { 105 | a.get(); 106 | b.get(); 107 | callCount++; 108 | }); 109 | 110 | a.set(1); 111 | b.set(1); 112 | 113 | // Non-batched changes are not called sync 114 | assert.strictEqual(callCount, 1); 115 | 116 | await 0; 117 | 118 | // Non-batched changes are called async 119 | assert.strictEqual(callCount, 2); 120 | }); 121 | 122 | test("handles mixed batched and unbatched changes", async () => { 123 | const a = new Signal.State(0); 124 | const b = new Signal.State(0); 125 | 126 | let callCount = 0; 127 | 128 | batchedEffect(() => { 129 | a.get(); 130 | b.get(); 131 | callCount++; 132 | }); 133 | 134 | a.set(1); 135 | 136 | batch(() => { 137 | b.set(1); 138 | }); 139 | 140 | // Effect callbacks are batched and called sync 141 | assert.strictEqual(callCount, 2); 142 | 143 | batch(() => { 144 | a.set(2); 145 | }); 146 | assert.strictEqual(callCount, 3); 147 | 148 | await 0; 149 | 150 | // No lingering effect calls 151 | assert.strictEqual(callCount, 3); 152 | }); 153 | 154 | test("exceptions in batches", () => { 155 | const a = new Signal.State(0); 156 | 157 | let callCount = 0; 158 | let errorCount = 0; 159 | 160 | batchedEffect(() => { 161 | a.get(); 162 | callCount++; 163 | }); 164 | 165 | try { 166 | batch(() => { 167 | a.set(1); 168 | throw new Error("oops"); 169 | }); 170 | } catch (e) { 171 | // Pass 172 | errorCount++; 173 | } 174 | 175 | // batch() propagates exceptions 176 | assert.strictEqual(errorCount, 1); 177 | 178 | // Effect callbacks still called if their dependencies were updated 179 | // before the exception 180 | assert.strictEqual(callCount, 2); 181 | 182 | // New batches still work 183 | 184 | batch(() => { 185 | a.set(2); 186 | }); 187 | 188 | assert.strictEqual(callCount, 3); 189 | }); 190 | 191 | test("exceptions in effects", async () => { 192 | const a = new Signal.State(0); 193 | 194 | let callCount1 = 0; 195 | let callCount2 = 0; 196 | let errorCount = 0; 197 | 198 | try { 199 | batchedEffect(() => { 200 | a.get(); 201 | callCount1++; 202 | throw new Error("oops"); 203 | }); 204 | } catch (e) { 205 | // Pass 206 | errorCount++; 207 | } 208 | 209 | // Effects are called immediately, so the exception is thrown immediately 210 | assert.strictEqual(errorCount, 1); 211 | 212 | // A second effect, to test that it still runs 213 | batchedEffect(() => { 214 | a.get(); 215 | callCount2++; 216 | }); 217 | 218 | try { 219 | batch(() => { 220 | a.set(1); 221 | }); 222 | } catch (e) { 223 | // Pass 224 | errorCount++; 225 | } 226 | 227 | // batch() propagates exceptions 228 | assert.strictEqual(errorCount, 2); 229 | assert.strictEqual(callCount1, 2); 230 | // Later effects are still called 231 | assert.strictEqual(callCount2, 2); 232 | }); 233 | 234 | test("disposes the effect", async () => { 235 | const a = new Signal.State(0); 236 | 237 | let callCount = 0; 238 | 239 | const dispose = batchedEffect(() => { 240 | a.get(); 241 | callCount++; 242 | }); 243 | 244 | assert.strictEqual(callCount, 1); 245 | 246 | batch(() => { 247 | a.set(1); 248 | }); 249 | 250 | assert.strictEqual(callCount, 2); 251 | 252 | // Trigger an async effect, before our dispose call 253 | a.set(2); 254 | 255 | dispose(); 256 | 257 | // Set the signal again from inside a batch 258 | batch(() => { 259 | a.set(3); 260 | }); 261 | 262 | // No lingering synchronous effect calls 263 | assert.strictEqual(callCount, 2); 264 | 265 | // No lingering asynchronous effect calls 266 | await 0; 267 | assert.strictEqual(callCount, 2); 268 | 269 | // Set the signal again outside a batch 270 | a.set(4); 271 | 272 | // No lingering asynchronous effect calls 273 | await 0; 274 | assert.strictEqual(callCount, 2); 275 | }); 276 | }); 277 | -------------------------------------------------------------------------------- /tests/subtle/microtask-effect.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, assert } from "vitest"; 2 | import { Signal } from "signal-polyfill"; 3 | import { effect } from "../../src/subtle/microtask-effect.ts"; 4 | import { waitForMicrotask } from "../helpers.ts"; 5 | 6 | describe("effect (via queueMicrotask)", () => { 7 | test("it works", async () => { 8 | let count = new Signal.State(0); 9 | 10 | let callCount = 0; 11 | 12 | effect(() => { 13 | count.get(); 14 | callCount++; 15 | }); 16 | 17 | assert.strictEqual(callCount, 1); 18 | 19 | count.set(count.get() + 1); 20 | await waitForMicrotask(); 21 | assert.strictEqual(callCount, 2); 22 | 23 | // is good enough to not freeze / OOM 24 | for (let i = 0; i < 25; i++) { 25 | count.set(count.get() + 1); 26 | await waitForMicrotask(); 27 | assert.strictEqual(callCount, 3 + i); 28 | } 29 | }); 30 | 31 | test("it allows unsubscribe", async () => { 32 | // Arrange 33 | let state = new Signal.State(0); 34 | let actualEffectedState = -1; 35 | const unsubscribe = effect(() => { 36 | actualEffectedState = state.get(); 37 | }); 38 | state.set(42); 39 | await waitForMicrotask(); 40 | 41 | // Act 42 | unsubscribe(); 43 | state.set(0); 44 | await waitForMicrotask(); 45 | 46 | // Assert 47 | assert.equal(actualEffectedState, 42); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/subtle/reaction.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, assert, afterEach } from "vitest"; 2 | import { Signal } from "signal-polyfill"; 3 | import { reaction, __internal_testing__ } from "../../src/subtle/reaction.ts"; 4 | 5 | afterEach(() => { 6 | __internal_testing__.active = false; 7 | }); 8 | 9 | describe("reaction()", () => { 10 | test("calls the effect function when the data function return value changes", async () => { 11 | const count = new Signal.State(0); 12 | 13 | let callCount = 0; 14 | let value, previousValue; 15 | 16 | reaction( 17 | () => count.get(), 18 | (_value, _previousValue) => { 19 | callCount++; 20 | value = _value; 21 | previousValue = _previousValue; 22 | }, 23 | ); 24 | 25 | // Effect callbacks are not called immediately 26 | assert.strictEqual(callCount, 0); 27 | 28 | await 0; 29 | 30 | // Effect callbacks are not called until the data function changes 31 | assert.strictEqual(callCount, 0); 32 | 33 | // Effect callbacks are called when the data function changes 34 | count.set(count.get() + 1); 35 | await 0; 36 | assert.strictEqual(callCount, 1); 37 | assert.strictEqual(value, 1); 38 | assert.strictEqual(previousValue, 0); 39 | 40 | // is good enough to not freeze / OOM 41 | for (let i = 0; i < 25; i++) { 42 | count.set(count.get() + 1); 43 | await 0; 44 | assert.strictEqual(callCount, 2 + i); 45 | assert.strictEqual(value, i + 2); 46 | assert.strictEqual(previousValue, i + 1); 47 | } 48 | }); 49 | 50 | test("Unsubscribed reactions aren't called", async () => { 51 | const count = new Signal.State(0); 52 | 53 | let callCount = 0; 54 | const unsubscribe = reaction( 55 | () => count.get(), 56 | () => { 57 | callCount++; 58 | }, 59 | ); 60 | 61 | // Check reaction is live 62 | count.set(count.get() + 1); 63 | await 0; 64 | assert.strictEqual(callCount, 1); 65 | 66 | unsubscribe(); 67 | 68 | // Check reaction is not live 69 | count.set(count.get() + 1); 70 | await 0; 71 | assert.strictEqual(callCount, 1); 72 | }); 73 | 74 | test("You can unsubscribe while an effect is pending", async () => { 75 | const count = new Signal.State(0); 76 | 77 | let callCount = 0; 78 | const unsubscribe = reaction( 79 | () => count.get(), 80 | () => { 81 | callCount++; 82 | }, 83 | ); 84 | 85 | // Check reaction is live 86 | count.set(count.get() + 1); 87 | await 0; 88 | assert.strictEqual(callCount, 1); 89 | 90 | // Check reaction is not live 91 | count.set(count.get() + 1); 92 | unsubscribe(); 93 | await 0; 94 | assert.strictEqual(callCount, 1); 95 | }); 96 | 97 | test("equal data values don't trigger effect", async () => { 98 | const a = new Signal.State(0); 99 | const b = new Signal.State(0); 100 | 101 | let callCount = 0; 102 | 103 | reaction( 104 | () => a.get() + b.get(), 105 | (_value, _previousValue) => { 106 | callCount++; 107 | }, 108 | ); 109 | 110 | // 1 + -1 still equals 0 111 | a.set(1); 112 | b.set(-1); 113 | await 0; 114 | assert.strictEqual(callCount, 0); 115 | }); 116 | 117 | test("throwing in effect doesn't hang reaction", async () => { 118 | const x = new Signal.State(0); 119 | 120 | let callCount = 0; 121 | let value, previousValue; 122 | let thrown = false; 123 | 124 | if (typeof process !== "undefined") { 125 | process.on("uncaughtException", (error) => { 126 | console.log("uncaughtException", error); 127 | }); 128 | } 129 | 130 | reaction( 131 | () => x.get(), 132 | (_value, _previousValue) => { 133 | callCount++; 134 | value = _value; 135 | previousValue = _previousValue; 136 | if (value === 1) { 137 | thrown = true; 138 | throw new Error("Oops"); 139 | } 140 | thrown = false; 141 | }, 142 | ); 143 | 144 | __internal_testing__.active = true; 145 | x.set(1); 146 | await 0; 147 | assert.strictEqual(callCount, 1); 148 | assert.strictEqual(thrown, true); 149 | assert.strictEqual( 150 | (__internal_testing__.lastError as Error).message, 151 | "Oops", 152 | ); 153 | 154 | x.set(2); 155 | await 0; 156 | assert.strictEqual(callCount, 2); 157 | assert.strictEqual(thrown, false); 158 | assert.strictEqual(value, 2); 159 | assert.strictEqual(previousValue, 1); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /tests/weak-map.test.ts: -------------------------------------------------------------------------------- 1 | import { SignalWeakMap } from "../src/weak-map.ts"; 2 | import { describe, test, assert } from "vitest"; 3 | 4 | import { reactivityTest } from "./helpers.ts"; 5 | 6 | describe("SignalWeakMap", function () { 7 | test("constructor", () => { 8 | const obj = {}; 9 | const map = new SignalWeakMap([[obj, 123]]); 10 | 11 | assert.equal(map.get(obj), 123); 12 | assert.ok(map instanceof WeakMap); 13 | }); 14 | 15 | test("does not work with built-ins", () => { 16 | const map = new SignalWeakMap(); 17 | 18 | assert.throws( 19 | // @ts-expect-error -- point is testing constructor error 20 | () => map.set("aoeu", 123), 21 | TypeError, 22 | ); 23 | assert.throws( 24 | // @ts-expect-error -- point is testing constructor error 25 | () => map.set(true, 123), 26 | TypeError, 27 | ); 28 | assert.throws( 29 | // @ts-expect-error -- point is testing constructor error 30 | () => map.set(123, 123), 31 | TypeError, 32 | ); 33 | assert.throws( 34 | // @ts-expect-error -- point is testing constructor error 35 | () => map.set(undefined, 123), 36 | TypeError, 37 | ); 38 | }); 39 | 40 | test("get/set", () => { 41 | const obj = {}; 42 | const map = new SignalWeakMap(); 43 | 44 | map.set(obj, 123); 45 | assert.equal(map.get(obj), 123); 46 | 47 | map.set(obj, 456); 48 | assert.equal(map.get(obj), 456); 49 | }); 50 | 51 | test("has", () => { 52 | const obj = {}; 53 | const map = new SignalWeakMap(); 54 | 55 | assert.equal(map.has(obj), false); 56 | map.set(obj, 123); 57 | assert.equal(map.has(obj), true); 58 | }); 59 | 60 | test("delete", () => { 61 | const obj = {}; 62 | const map = new SignalWeakMap(); 63 | 64 | assert.equal(map.has(obj), false); 65 | 66 | map.set(obj, 123); 67 | assert.equal(map.has(obj), true); 68 | 69 | map.delete(obj); 70 | assert.equal(map.has(obj), false); 71 | }); 72 | 73 | reactivityTest( 74 | "get/set", 75 | class { 76 | obj = {}; 77 | map = new SignalWeakMap(); 78 | 79 | get value() { 80 | return this.map.get(this.obj); 81 | } 82 | 83 | update() { 84 | this.map.set(this.obj, 123); 85 | } 86 | }, 87 | ); 88 | 89 | reactivityTest( 90 | "get/set existing value", 91 | class { 92 | obj = {}; 93 | map = new SignalWeakMap([[this.obj, 456]]); 94 | 95 | get value() { 96 | return this.map.get(this.obj); 97 | } 98 | 99 | update() { 100 | this.map.set(this.obj, 123); 101 | } 102 | }, 103 | ); 104 | 105 | reactivityTest( 106 | "get/set unrelated value", 107 | class { 108 | obj = {}; 109 | obj2 = {}; 110 | map = new SignalWeakMap([[this.obj, 456]]); 111 | 112 | get value() { 113 | return this.map.get(this.obj); 114 | } 115 | 116 | update() { 117 | this.map.set(this.obj2, 123); 118 | } 119 | }, 120 | false, 121 | ); 122 | 123 | reactivityTest( 124 | "has", 125 | class { 126 | obj = {}; 127 | map = new SignalWeakMap(); 128 | 129 | get value() { 130 | return this.map.has(this.obj); 131 | } 132 | 133 | update() { 134 | this.map.set(this.obj, 123); 135 | } 136 | }, 137 | ); 138 | 139 | reactivityTest( 140 | "delete", 141 | class { 142 | obj = {}; 143 | map = new SignalWeakMap([[this.obj, 123]]); 144 | 145 | get value() { 146 | return this.map.get(this.obj); 147 | } 148 | 149 | update() { 150 | this.map.delete(this.obj); 151 | } 152 | }, 153 | ); 154 | 155 | reactivityTest( 156 | "delete unrelated value", 157 | class { 158 | obj = {}; 159 | obj2 = {}; 160 | map = new SignalWeakMap([ 161 | [this.obj, 123], 162 | [this.obj2, 456], 163 | ]); 164 | 165 | get value() { 166 | return this.map.get(this.obj); 167 | } 168 | 169 | update() { 170 | this.map.delete(this.obj2); 171 | } 172 | }, 173 | false, 174 | ); 175 | }); 176 | -------------------------------------------------------------------------------- /tests/weak-set.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, assert } from "vitest"; 2 | import { SignalWeakSet } from "../src/weak-set.ts"; 3 | 4 | import { reactivityTest } from "./helpers.ts"; 5 | 6 | describe("SignalWeakSet", function () { 7 | test("constructor", () => { 8 | const obj = {}; 9 | const set = new SignalWeakSet([obj]); 10 | 11 | assert.equal(set.has(obj), true); 12 | assert.ok(set instanceof WeakSet); 13 | 14 | const array = [1, 2, 3]; 15 | const iterable = [array]; 16 | const fromIterable = new SignalWeakSet(iterable); 17 | assert.equal(fromIterable.has(array), true); 18 | }); 19 | 20 | test("does not work with built-ins", () => { 21 | const set = new SignalWeakSet(); 22 | 23 | // @ts-expect-error -- point is testing constructor error 24 | assert.throws(() => set.add("aoeu"), TypeError); 25 | // @ts-expect-error -- point is testing constructor error 26 | assert.throws(() => set.add(true), TypeError); 27 | // @ts-expect-error -- point is testing constructor error 28 | assert.throws(() => set.add(123), TypeError); 29 | // @ts-expect-error -- point is testing constructor error 30 | assert.throws(() => set.add(undefined), TypeError); 31 | }); 32 | 33 | test("add/has", () => { 34 | const obj = {}; 35 | const set = new SignalWeakSet(); 36 | 37 | set.add(obj); 38 | assert.equal(set.has(obj), true); 39 | }); 40 | 41 | test("delete", () => { 42 | const obj = {}; 43 | const set = new SignalWeakSet(); 44 | 45 | assert.equal(set.has(obj), false); 46 | 47 | set.add(obj); 48 | assert.equal(set.has(obj), true); 49 | 50 | set.delete(obj); 51 | assert.equal(set.has(obj), false); 52 | }); 53 | 54 | reactivityTest( 55 | "add/has", 56 | class { 57 | obj = {}; 58 | set = new SignalWeakSet(); 59 | 60 | get value() { 61 | return this.set.has(this.obj); 62 | } 63 | 64 | update() { 65 | this.set.add(this.obj); 66 | } 67 | }, 68 | ); 69 | 70 | reactivityTest( 71 | "add/has existing value", 72 | class { 73 | obj = {}; 74 | obj2 = {}; 75 | set = new SignalWeakSet([this.obj]); 76 | 77 | get value() { 78 | return this.set.has(this.obj); 79 | } 80 | 81 | update() { 82 | this.set.add(this.obj); 83 | } 84 | }, 85 | ); 86 | 87 | reactivityTest( 88 | "add/has unrelated value", 89 | class { 90 | obj = {}; 91 | obj2 = {}; 92 | set = new SignalWeakSet(); 93 | 94 | get value() { 95 | return this.set.has(this.obj); 96 | } 97 | 98 | update() { 99 | this.set.add(this.obj2); 100 | } 101 | }, 102 | false, 103 | ); 104 | 105 | reactivityTest( 106 | "delete", 107 | class { 108 | obj = {}; 109 | obj2 = {}; 110 | set = new SignalWeakSet([this.obj, this.obj2]); 111 | 112 | get value() { 113 | return this.set.has(this.obj); 114 | } 115 | 116 | update() { 117 | this.set.delete(this.obj); 118 | } 119 | }, 120 | ); 121 | 122 | reactivityTest( 123 | "delete unrelated value", 124 | class { 125 | obj = {}; 126 | obj2 = {}; 127 | set = new SignalWeakSet([this.obj, this.obj2]); 128 | 129 | get value() { 130 | return this.set.has(this.obj); 131 | } 132 | 133 | update() { 134 | this.set.delete(this.obj2); 135 | } 136 | }, 137 | false, 138 | ); 139 | }); 140 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "@tsconfig/strictest", 4 | "include": ["src", "tests"], 5 | "exclude": ["dist", "declarations"], 6 | "compilerOptions": { 7 | "target": "esnext", 8 | "module": "preserve", 9 | "moduleResolution": "bundler", 10 | "declaration": true, 11 | "emitDeclarationOnly": true, 12 | "declarationMap": true, 13 | 14 | /** 15 | Stylistic / linting. Does not provide extra type safety. 16 | */ 17 | "noPropertyAccessFromIndexSignature": false, 18 | 19 | /** 20 | https://www.typescriptlang.org/tsconfig#allowImportingTsExtensions 21 | 22 | We want our tooling to know how to resolve our custom files so the appropriate plugins 23 | can do the proper transformations on those files. 24 | */ 25 | "allowImportingTsExtensions": true, 26 | 27 | /** 28 | We don't want to include types dependencies in our compiled output, so tell TypeScript 29 | to enforce using `import type` instead of `import` for Types. 30 | */ 31 | "verbatimModuleSyntax": true, 32 | 33 | /** 34 | Don't implicitly pull in declarations from `@types` packages unless we 35 | actually import from them AND the package in question doesn't bring its 36 | own types. You may wish to override this e.g. with `"types": ["node"]` 37 | if your project has build-time elements that use NodeJS APIs. 38 | */ 39 | "types": [], 40 | 41 | /** 42 | for easier test authoring. 43 | note that all imports under `src` should use relative or subpath-imports 44 | (defined in packaeg.json) 45 | */ 46 | "baseUrl": "./src", 47 | "paths": { 48 | "signal-utils": ["./index.ts"], 49 | "signal-utils/*": ["./*.ts"] 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path, { basename } from "node:path"; 2 | import { createRequire } from "node:module"; 3 | import { defineConfig } from "vite"; 4 | import dts from "vite-plugin-dts"; 5 | import { globbySync } from "globby"; 6 | import { babel } from "@rollup/plugin-babel"; 7 | 8 | const require = createRequire(import.meta.url); 9 | const manifest = require("./package.json"); 10 | 11 | let entryFiles = globbySync(["src/**/*.ts"], { ignore: ["**/*.d.ts"] }); 12 | 13 | let entries: Record = {}; 14 | 15 | for (let entry of entryFiles) { 16 | let name = basename(entry); 17 | entries[name] = entry; 18 | } 19 | 20 | export default defineConfig({ 21 | // esbuild in vite does not support decorators 22 | // https://github.com/evanw/esbuild/issues/104 23 | esbuild: false, 24 | build: { 25 | outDir: "dist", 26 | // These targets are not "support". 27 | // A consuming app or library should compile further if they need to support 28 | // old browsers. 29 | target: ["esnext", "firefox121"], 30 | // In case folks debug without sourcemaps 31 | // 32 | // TODO: do a dual build, split for development + production 33 | // where production is optimized for CDN loading via 34 | // https://limber.glimdown.com 35 | minify: false, 36 | sourcemap: true, 37 | rollupOptions: { 38 | output: { 39 | dir: "dist", 40 | experimentalMinChunkSize: 0, 41 | format: "es", 42 | hoistTransitiveImports: false, 43 | sourcemap: true, 44 | entryFileNames: (entry) => { 45 | const { name, facadeModuleId } = entry; 46 | const fileName = `${name}.js`; 47 | if (!facadeModuleId) { 48 | return fileName; 49 | } 50 | const relativeDir = path.relative( 51 | path.resolve(__dirname, "src"), 52 | path.dirname(facadeModuleId), 53 | ); 54 | return path.join(relativeDir, fileName); 55 | }, 56 | }, 57 | external: [ 58 | ...Object.keys(manifest.dependencies || {}), 59 | ...Object.keys(manifest.peerDependencies || {}), 60 | ], 61 | }, 62 | lib: { 63 | entry: entries, 64 | name: "signal-utils", 65 | formats: ["es"], 66 | }, 67 | }, 68 | plugins: [ 69 | babel({ 70 | babelHelpers: "inline", 71 | extensions: [".js", ".ts"], 72 | }), 73 | dts({ 74 | // This can generate duplicate types in the d.ts files 75 | // rollupTypes: true, 76 | outDir: "declarations", 77 | // ignore tests 78 | include: ["src"], 79 | }), 80 | ], 81 | }); 82 | --------------------------------------------------------------------------------