major refactor: Use alien-signals v2, support for watching patches

main
Laurin Weger 2 weeks ago
parent b691dc9202
commit 58e5e96fa3
No known key found for this signature in database
GPG Key ID: 9B372BB0B792770F
  1. 2
      .gitignore
  2. 54
      README.md
  3. 5
      package.json
  4. 184
      pnpm-lock.yaml
  5. 402
      src/core.ts
  6. 803
      src/deepSignal.ts
  7. 6
      src/index.ts
  8. 81
      src/test/coreDeepSignal.coverage.test.ts
  9. 2222
      src/test/index.test.ts
  10. 76
      src/test/patchOptimized.test.ts
  11. 199
      src/test/tier3.test.ts
  12. 137
      src/test/watch.test.ts
  13. 339
      src/test/watchPatches.test.ts
  14. 350
      src/watch.ts
  15. 13
      src/watchEffect.ts

2
.gitignore vendored

@ -20,3 +20,5 @@ dist-ssr
*.suo *.suo
*.sln *.sln
*.sw? *.sw?
coverage

@ -68,3 +68,57 @@ watch(state,(value)=>{
}) })
``` ```
### Advanced watching & patch stream
There are two layers:
1. `watch()` (high-level) – subscribe to value changes of signals, deepSignal objects, getters, or arrays of them. Supports options:
- `immediate` (fire once right away)
- `deep` (traverse nested properties to collect deps)
- `once` (auto-dispose after first emission)
- `patchOptimized` (when `deep` + deepSignal, skip full traversal and rely on internal mutation patches).
2. Patch stream (low-level) – internal `subscribeDeepMutations()` used by `patchOptimized` and exposed via new helpers below.
#### patchOptimized
Deep watches normally trigger a full recursive traversal to register dependencies, which can be expensive for large trees. With `patchOptimized: true`, a hidden version counter signal is incremented only when a relevant deep mutation patch batch is emitted. That means no repeated deep traversal per change—performance scales with number of actual mutations instead of tree size.
```ts
watch(state, (val) => {
render(val)
}, { deep: true, patchOptimized: true })
```
### New helper APIs (optional sugar)
```ts
import { watchPatches, observe } from 'alien-deepsignals'
// 1. watchPatches: directly receive deep mutation patch batches for a deepSignal root
const stop = watchPatches(state, (patches) => {
for (const p of patches) {
console.log(p.type, p.path.join('.'), p.value)
}
})
// 2. observe: unified API for value or patch modes
// value mode (essentially watch())
const offValue = observe(state, (val, old) => {
console.log('value changed', old, '=>', val)
}, { mode: 'value', deep: true, patchOptimized: true })
// patch mode (delegates to watchPatches)
const offPatches = observe(state, (patches) => {
console.log('patch batch', patches)
}, { mode: 'patch' })
```
Modes summary:
| API | Emits | Use-case |
|-----|-------|----------|
| `watch` / `observe(..., {mode:'value'})` | New value + old value | Derive computations, side effects |
| `watchPatches` / `observe(..., {mode:'patch'})` | Array of `{root,type,path,value}` | Sync external stores/UI diff |
In patch mode only structural mutations trigger callbacks; reads do not cause traversal.

@ -18,16 +18,17 @@
}, },
"description": "AlienDeepSignals 🧶 -alien signals, but using regular JavaScript objects", "description": "AlienDeepSignals 🧶 -alien signals, but using regular JavaScript objects",
"scripts": { "scripts": {
"test": "vitest", "test": "vitest --coverage",
"dev": "tsup --watch src", "dev": "tsup --watch src",
"build": "tsup", "build": "tsup",
"release": "bumpp && npm run build && npm publish --registry=https://registry.npmjs.org/" "release": "bumpp && npm run build && npm publish --registry=https://registry.npmjs.org/"
}, },
"dependencies": { "dependencies": {
"alien-signals": "^1.0.0" "alien-signals": "^2.0.7"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.9", "@types/node": "^22.10.9",
"@vitest/coverage-v8": "3.0.2",
"bumpp": "^9.9.2", "bumpp": "^9.9.2",
"tsup": "^8.3.5", "tsup": "^8.3.5",
"typescript": "^5.4.3", "typescript": "^5.4.3",

@ -9,15 +9,18 @@ importers:
.: .:
dependencies: dependencies:
alien-signals: alien-signals:
specifier: ^1.0.0 specifier: ^2.0.7
version: 1.0.0 version: 2.0.7
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: ^22.10.9 specifier: ^22.10.9
version: 22.10.9 version: 22.10.9
'@vitest/coverage-v8':
specifier: 3.0.2
version: 3.0.2(vitest@3.0.2(@types/node@22.10.9))
bumpp: bumpp:
specifier: ^9.9.2 specifier: ^9.9.2
version: 9.9.2 version: 9.9.2(magicast@0.3.5)
tsup: tsup:
specifier: ^8.3.5 specifier: ^8.3.5
version: 8.3.5(jiti@2.4.2)(postcss@8.4.49)(typescript@5.7.2) version: 8.3.5(jiti@2.4.2)(postcss@8.4.49)(typescript@5.7.2)
@ -30,6 +33,31 @@ importers:
packages: packages:
'@ampproject/remapping@2.3.0':
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.27.1':
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.28.3':
resolution: {integrity: sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.28.2':
resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@esbuild/aix-ppc64@0.21.5': '@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -322,6 +350,10 @@ packages:
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'} engines: {node: '>=12'}
'@istanbuljs/schema@0.1.3':
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
engines: {node: '>=8'}
'@jridgewell/gen-mapping@0.3.8': '@jridgewell/gen-mapping@0.3.8':
resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
@ -445,6 +477,15 @@ packages:
'@types/node@22.10.9': '@types/node@22.10.9':
resolution: {integrity: sha512-Ir6hwgsKyNESl/gLOcEz3krR4CBGgliDqBQ2ma4wIhEx0w+xnoeTq3tdrNw15kU3SxogDjOgv9sqdtLW8mIHaw==} resolution: {integrity: sha512-Ir6hwgsKyNESl/gLOcEz3krR4CBGgliDqBQ2ma4wIhEx0w+xnoeTq3tdrNw15kU3SxogDjOgv9sqdtLW8mIHaw==}
'@vitest/coverage-v8@3.0.2':
resolution: {integrity: sha512-U+hZYb0FtgNDb6B3E9piAHzXXIuxuBw2cd6Lvepc9sYYY4KjgiwCBmo3Sird9ZRu3ggLpLBTfw1ZRr77ipiSfw==}
peerDependencies:
'@vitest/browser': 3.0.2
vitest: 3.0.2
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@3.0.2': '@vitest/expect@3.0.2':
resolution: {integrity: sha512-dKSHLBcoZI+3pmP5hiZ7I5grNru2HRtEW8Z5Zp4IXog8QYcxhlox7JUPyIIFWfN53+3HW3KPLIl6nSzUGgKSuQ==} resolution: {integrity: sha512-dKSHLBcoZI+3pmP5hiZ7I5grNru2HRtEW8Z5Zp4IXog8QYcxhlox7JUPyIIFWfN53+3HW3KPLIl6nSzUGgKSuQ==}
@ -479,8 +520,8 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
alien-signals@1.0.0: alien-signals@2.0.7:
resolution: {integrity: sha512-Fd2sYMdyjWD6VKxeewCYHXsIYAiELGMtQzGJ6vyxpxtQ1exXYiNTynSqGllkk+mOqhtBFYcC1Qvb49FbCSvsQw==} resolution: {integrity: sha512-wE7y3jmYeb0+h6mr5BOovuqhFv22O/MV9j5p0ndJsa7z1zJNPGQ4ph5pQk/kTTCWRC3xsA4SmtwmkzQO+7NCNg==}
ansi-regex@5.0.1: ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
@ -671,6 +712,13 @@ packages:
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
hasBin: true hasBin: true
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
human-signals@5.0.0: human-signals@5.0.0:
resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
engines: {node: '>=16.17.0'} engines: {node: '>=16.17.0'}
@ -686,6 +734,22 @@ packages:
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-lib-source-maps@5.0.6:
resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==}
engines: {node: '>=10'}
istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
jackspeak@3.4.3: jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
@ -731,6 +795,13 @@ packages:
magic-string@0.30.17: magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
magicast@0.3.5:
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
merge-stream@2.0.0: merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@ -950,10 +1021,18 @@ packages:
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
hasBin: true hasBin: true
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
tar@6.2.1: tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'} engines: {node: '>=10'}
test-exclude@7.0.1:
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
engines: {node: '>=18'}
thenify-all@1.6.0: thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
@ -1113,6 +1192,26 @@ packages:
snapshots: snapshots:
'@ampproject/remapping@2.3.0':
dependencies:
'@jridgewell/gen-mapping': 0.3.8
'@jridgewell/trace-mapping': 0.3.25
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.27.1': {}
'@babel/parser@7.28.3':
dependencies:
'@babel/types': 7.28.2
'@babel/types@7.28.2':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@bcoe/v8-coverage@1.0.2': {}
'@esbuild/aix-ppc64@0.21.5': '@esbuild/aix-ppc64@0.21.5':
optional: true optional: true
@ -1266,6 +1365,8 @@ snapshots:
wrap-ansi: 8.1.0 wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0 wrap-ansi-cjs: wrap-ansi@7.0.0
'@istanbuljs/schema@0.1.3': {}
'@jridgewell/gen-mapping@0.3.8': '@jridgewell/gen-mapping@0.3.8':
dependencies: dependencies:
'@jridgewell/set-array': 1.2.1 '@jridgewell/set-array': 1.2.1
@ -1349,6 +1450,24 @@ snapshots:
dependencies: dependencies:
undici-types: 6.20.0 undici-types: 6.20.0
'@vitest/coverage-v8@3.0.2(vitest@3.0.2(@types/node@22.10.9))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
debug: 4.4.0
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6
istanbul-reports: 3.2.0
magic-string: 0.30.17
magicast: 0.3.5
std-env: 3.8.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
vitest: 3.0.2(@types/node@22.10.9)
transitivePeerDependencies:
- supports-color
'@vitest/expect@3.0.2': '@vitest/expect@3.0.2':
dependencies: dependencies:
'@vitest/spy': 3.0.2 '@vitest/spy': 3.0.2
@ -1391,7 +1510,7 @@ snapshots:
acorn@8.14.0: {} acorn@8.14.0: {}
alien-signals@1.0.0: {} alien-signals@2.0.7: {}
ansi-regex@5.0.1: {} ansi-regex@5.0.1: {}
@ -1415,9 +1534,9 @@ snapshots:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
bumpp@9.9.2: bumpp@9.9.2(magicast@0.3.5):
dependencies: dependencies:
c12: 2.0.1 c12: 2.0.1(magicast@0.3.5)
cac: 6.7.14 cac: 6.7.14
escalade: 3.2.0 escalade: 3.2.0
js-yaml: 4.1.0 js-yaml: 4.1.0
@ -1434,7 +1553,7 @@ snapshots:
esbuild: 0.24.2 esbuild: 0.24.2
load-tsconfig: 0.2.5 load-tsconfig: 0.2.5
c12@2.0.1: c12@2.0.1(magicast@0.3.5):
dependencies: dependencies:
chokidar: 4.0.3 chokidar: 4.0.3
confbox: 0.1.8 confbox: 0.1.8
@ -1448,6 +1567,8 @@ snapshots:
perfect-debounce: 1.0.0 perfect-debounce: 1.0.0
pkg-types: 1.3.0 pkg-types: 1.3.0
rc9: 2.1.2 rc9: 2.1.2
optionalDependencies:
magicast: 0.3.5
cac@6.7.14: {} cac@6.7.14: {}
@ -1621,6 +1742,10 @@ snapshots:
package-json-from-dist: 1.0.1 package-json-from-dist: 1.0.1
path-scurry: 1.11.1 path-scurry: 1.11.1
has-flag@4.0.0: {}
html-escaper@2.0.2: {}
human-signals@5.0.0: {} human-signals@5.0.0: {}
is-fullwidth-code-point@3.0.0: {} is-fullwidth-code-point@3.0.0: {}
@ -1629,6 +1754,27 @@ snapshots:
isexe@2.0.0: {} isexe@2.0.0: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-lib-source-maps@5.0.6:
dependencies:
'@jridgewell/trace-mapping': 0.3.25
debug: 4.4.0
istanbul-lib-coverage: 3.2.2
transitivePeerDependencies:
- supports-color
istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
jackspeak@3.4.3: jackspeak@3.4.3:
dependencies: dependencies:
'@isaacs/cliui': 8.0.2 '@isaacs/cliui': 8.0.2
@ -1663,6 +1809,16 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.0
magicast@0.3.5:
dependencies:
'@babel/parser': 7.28.3
'@babel/types': 7.28.2
source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
semver: 7.6.3
merge-stream@2.0.0: {} merge-stream@2.0.0: {}
mimic-fn@4.0.0: {} mimic-fn@4.0.0: {}
@ -1867,6 +2023,10 @@ snapshots:
pirates: 4.0.6 pirates: 4.0.6
ts-interface-checker: 0.1.13 ts-interface-checker: 0.1.13
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
tar@6.2.1: tar@6.2.1:
dependencies: dependencies:
chownr: 2.0.0 chownr: 2.0.0
@ -1876,6 +2036,12 @@ snapshots:
mkdirp: 1.0.4 mkdirp: 1.0.4
yallist: 4.0.0 yallist: 4.0.0
test-exclude@7.0.1:
dependencies:
'@istanbuljs/schema': 0.1.3
glob: 10.4.5
minimatch: 9.0.5
thenify-all@1.6.0: thenify-all@1.6.0:
dependencies: dependencies:
thenify: 3.3.1 thenify: 3.3.1

@ -1,244 +1,188 @@
import { createReactiveSystem, Dependency, Link, Subscriber, SubscriberFlags } from 'alien-signals'; /**
import { ReactiveFlags } from "./contents" * Core signal utilities (v2 native layer + tiny ergonomic shims)
import { isFunction } from './utils'; * ------------------------------------------------------------------
const { * We lean directly on alien-signals v2 which exposes function-form primitives:
link, * const count = signal(0) => invoke to read, invoke with arg to write.
propagate, * const doubled = computed(() => count() * 2)
endTracking, * effect(() => console.log(doubled()))
startTracking, *
updateDirtyFlag, * This file adds a very small façade so existing code/tests that relied on:
processComputedUpdate, * - obj.$prop.value style access
processEffectNotifications, * - signal.value / signal.peek()
} = createReactiveSystem({ * - (Optionally supported earlier) instanceof checks removed now for leaner runtime.
updateComputed(computed: Computed) { * still work while everything internally is the native function signal.
return computed.update(); *
}, * Tagging strategy:
notifyEffect(effect: Effect) { * - The created function is augmented with .value (getter/setter) and .peek() (non-tracking read alias).
effect.notify(); * - A Symbol flag (ReactiveFlags_.IS_SIGNAL) is attached for runtime guards.
return true; * - Prototype of the function is set to a dummy constructor (Signal / Computed) enabling instanceof checks.
}, *
}); * Effect wrapper:
* - The watch implementation historically expected a class with .run(), .stop(), .dirty & .scheduler.
let activeSub: Subscriber | undefined = undefined; * - We wrap a native alienEffect; every native re-run marks dirty=true then calls user getter + scheduler.
let batchDepth = 0; * - run() simply executes the getter without re-registering a new effect (leveraging the existing one).
*/
export function startBatch(): void { // Native v2 re-export with minimal tagging for .value convenience.
++batchDepth; /**
} * Raw native alien-signals v2 primitives re-exported for advanced consumers wanting
* to bypass the tagging helpers (exposing the bare function signals/computed/effect batch APIs).
export function endBatch(): void { *
if (!--batchDepth) { * - `_rawSignal` / `_rawComputed` are the untagged constructors.
processEffectNotifications(); * - `effect`, `startBatch`, `endBatch`, `getCurrentSub`, `setCurrentSub` are passed through unchanged.
} */
export {
signal as _rawSignal,
computed as _rawComputed,
effect,
startBatch,
endBatch,
getCurrentSub,
setCurrentSub,
} from "alien-signals";
import {
signal as alienSignal,
computed as alienComputed,
effect as alienEffect,
startBatch as alienStartBatch,
endBatch as alienEndBatch,
} from "alien-signals";
import { ReactiveFlags as ReactiveFlags_ } from "./contents";
import { isFunction } from "./utils";
// Nominal constructor removal: we no longer expose classes; signals are plain tagged functions.
/** Internal shape of a tagged writable signal after adding ergonomic helpers. */
type TaggedSignal<T> = ReturnType<typeof alienSignal<T>> & {
peek(): T;
get(): T;
set(v: T): void;
};
/**
* Decorate a native signal function with legacy helpers & identity.
*/
function tagSignal(fn: any): TaggedSignal<any> {
Object.defineProperty(fn, ReactiveFlags_.IS_SIGNAL, { value: true });
Object.defineProperty(fn, "value", {
get: () => fn(),
set: (v) => fn(v),
});
// Add peek to mirror old API (non-tracking read)
if (!fn.peek) Object.defineProperty(fn, "peek", { value: () => fn() });
if (!fn.get) Object.defineProperty(fn, "get", { value: () => fn() });
if (!fn.set) Object.defineProperty(fn, "set", { value: (v: any) => fn(v) });
return fn;
} }
export function signal<T>(): Signal<T | undefined>; /**
export function signal<T>(oldValue: T): Signal<T>; * Decorate a native computed function similarly (readonly value accessor).
export function signal<T>(oldValue?: T): Signal<T | undefined> { */
return new Signal(oldValue); function tagComputed(fn: any) {
Object.defineProperty(fn, ReactiveFlags_.IS_SIGNAL, { value: true });
Object.defineProperty(fn, "value", { get: () => fn() });
if (!fn.peek) Object.defineProperty(fn, "peek", { value: () => fn() });
if (!fn.get) Object.defineProperty(fn, "get", { value: () => fn() });
return fn;
} }
export class Signal<T = any> implements Dependency { /**
public readonly [ReactiveFlags.IS_SIGNAL] = true * Create a new writable function-form signal enhanced with `.value`, `.peek()`, `.get()`, `.set()`.
public readonly [ReactiveFlags.SKIP] = true *
// Dependency fields * @example
subs: Link | undefined = undefined; * const count = signal(0);
subsTail: Link | undefined = undefined; * count(); // 0 (track)
* count(1); // write
constructor( * count.value; // 1 (track)
public currentValue: T * count.peek(); // 1 (non-tracking)
) { } */
export const signal = <T>(v?: T) => tagSignal(alienSignal(v));
get(): T { /**
if (activeSub !== undefined) { * Create a lazy computed (readonly) signal derived from other signals.
link(this, activeSub); * The returned function is tagged with `.value` and `.peek()` for convenience.
} */
return this.currentValue; export const computed = <T>(getter: () => T) =>
} tagComputed(alienComputed(getter));
set(value: T): void { /** Union allowing a plain value or a writable signal wrapping that value. */
if (this.currentValue !== value) { export type MaybeSignal<T = any> = T | ReturnType<typeof signal>;
this.currentValue = value; /** Union allowing value, writable signal, computed signal or plain getter function. */
const subs = this.subs; export type MaybeSignalOrGetter<T = any> =
if (subs !== undefined) { | MaybeSignal<T>
propagate(subs); | ReturnType<typeof computed>
if (!batchDepth) { | (() => T);
processEffectNotifications(); /** Runtime guard that an unknown value is one of our tagged signals/computeds. */
} export const isSignal = (s: any): boolean =>
} typeof s === "function" && !!s && !!s[ReactiveFlags_.IS_SIGNAL];
/**
* Minimal Effect wrapper for legacy watch implementation.
* Provides: active, dirty, scheduler hook, run() & stop().
*/
/**
* Minimal Effect wrapper mimicking the legacy interface used by the watch implementation.
*
* Each instance wraps a native alien `effect`, setting `dirty=true` on invalidation and invoking
* the provided scheduler callback. Consumers may manually `run()` the getter (marks clean) or `stop()`
* to dispose the underlying reactive subscription.
*/
export class Effect {
public active = true;
public dirty = true;
public scheduler: (immediateFirstRun?: boolean) => void = () => {};
private _runner: any;
constructor(private _getter: () => any) {
const self = this;
this._runner = alienEffect(function wrapped() {
self.dirty = true;
self._getter();
self.scheduler();
});
}
run() {
this.dirty = false;
return this._getter();
}
stop() {
if (this.active) {
this._runner();
this.active = false;
} }
} }
get value(): T {
return this.get();
}
set value(value: T) {
this.set(value);
}
peek(): T {
return this.currentValue;
}
}
export function computed<T>(getter: () => T): Computed<T> {
return new Computed<T>(getter);
}
export class Computed<T = any> implements Subscriber, Dependency {
readonly [ReactiveFlags.IS_SIGNAL] = true
currentValue: T | undefined = undefined;
// Dependency fields
subs: Link | undefined = undefined;
subsTail: Link | undefined = undefined;
// Subscriber fields
deps: Link | undefined = undefined;
depsTail: Link | undefined = undefined;
flags: SubscriberFlags = SubscriberFlags.Computed | SubscriberFlags.Dirty;
constructor(
public getter: () => T
) { }
get(): T {
const flags = this.flags;
if (flags & (SubscriberFlags.PendingComputed | SubscriberFlags.Dirty)) {
processComputedUpdate(this, flags);
}
if (activeSub !== undefined) {
link(this, activeSub);
}
return this.currentValue!;
}
update(): boolean {
const prevSub = activeSub;
activeSub = this;
startTracking(this);
try {
const oldValue = this.currentValue;
const newValue = this.getter();
if (oldValue !== newValue) {
this.currentValue = newValue;
return true;
}
return false;
} finally {
activeSub = prevSub;
endTracking(this);
}
}
get value(): Readonly<T> {
return this.get();
}
peek(): T {
return this.currentValue!;
}
}
export function effect<T>(fn: () => T): Effect<T> {
const e = new Effect(fn);
e.run();
return e;
} }
/** Resolve a plain value, a signal/computed or a getter function to its current value. */
export enum EffectFlags { // Lightweight direct resolver (inlined former toValue/unSignal logic)
/** /**
* ReactiveEffect only * Resolve a possibly reactive input to its current value.
*/ * Accepts: plain value, writable signal, computed signal, or getter function.
ALLOW_RECURSE = 1 << 7, * Signals & getters are invoked once; plain values are returned directly.
PAUSED = 1 << 8, */
NOTIFIED = 1 << 9, export function toValue<T>(src: MaybeSignalOrGetter<T>): T {
STOP = 1 << 10, return isFunction(src)
} ? (src as any)()
: isSignal(src)
export class Effect<T = any> implements Subscriber { ? (src as any)()
readonly [ReactiveFlags.IS_SIGNAL] = true : (src as any);
// Subscriber fields
deps: Link | undefined = undefined;
depsTail: Link | undefined = undefined;
flags: SubscriberFlags = SubscriberFlags.Effect;
constructor(
public fn: () => T
) { }
notify(): void {
const flags = this.flags;
if (
flags & SubscriberFlags.Dirty
|| (flags & SubscriberFlags.PendingComputed && updateDirtyFlag(this, flags))
) {
this.scheduler();
}
}
scheduler(): void {
if (this.dirty) {
this.run()
}
}
get active(): boolean {
return !(this.flags & EffectFlags.STOP)
}
get dirty(): boolean {
const flags = this.flags
if (
flags & SubscriberFlags.Dirty ||
(flags & SubscriberFlags.PendingComputed && updateDirtyFlag(this, flags))
) {
return true
}
return false
}
run(): T {
const prevSub = activeSub;
activeSub = this;
startTracking(this);
try {
return this.fn();
} finally {
activeSub = prevSub;
endTracking(this);
}
}
stop(): void {
startTracking(this);
endTracking(this);
}
} }
/**
* Execute multiple signal writes in a single batched update frame.
* All downstream computed/effect re-evaluations are deferred until the function exits.
*
* IMPORTANT: The callback MUST be synchronous. If it returns a Promise the batch will
* still end immediately after scheduling, possibly causing mid-async flushes.
*
* @example
* batch(() => {
* count(count() + 1);
* other(other() + 2);
* }); // effects observing both run only once
*/
export function batch<T>(fn: () => T): T { export function batch<T>(fn: () => T): T {
startBatch(); alienStartBatch();
try { try {
return fn(); return fn();
} finally { } finally {
endBatch(); alienEndBatch();
} }
} }
export function isSignal<T>(r: Signal<T> | unknown): r is Signal<T>
export function isSignal(s: any): s is Signal {
return s ? s[ReactiveFlags.IS_SIGNAL] === true : false
}
export type MaybeSignal<T = any> =
| T
| Signal<T>
export type MaybeSignalOrGetter<T = any> = MaybeSignal<T> | Computed<T> | (() => T)
export function unSignal<T>(signal: MaybeSignal<T> | Computed<T>): T {
return (isSignal(signal) ? signal.value : signal) as T;
}
export function toValue<T>(source: MaybeSignalOrGetter<T>): T {
return isFunction(source) ? source() : unSignal(source)
}

@ -1,30 +1,397 @@
import { ReactiveFlags } from "./contents"; import { ReactiveFlags } from "./contents";
import { computed, Signal, signal } from "./core"; import { computed, signal, isSignal } from "./core";
/**
* Implementation overview (supplementary to header docs):
*
* Data structures:
* - proxyToSignals: WeakMap<proxy, Map<prop, signalFn>> maps each proxied object to its per-key signal.
* - objToProxy: WeakMap<rawObject, proxy> ensures stable proxy instance per raw object.
* - arrayToArrayOfSignals: WeakMap<array, proxy> holds special `$` array meta proxy (index signals + length).
* - ignore: WeakSet marks objects already proxied or shallow-wrapped to avoid double proxying.
* - objToIterable: WeakMap<object, signal<number>> used to re-trigger enumeration dependent computed/effects.
* - proxyMeta: WeakMap<proxy, { parent, key, root }> chain info for patch path reconstruction.
*
* Key decisions:
* - Signals are created lazily: first read of value or its `$` accessor.
* - Getter properties become computed signals (readonly) so derived values stay consistent.
* - Setting via `$prop` enforces passing a signal (allows external signal assignment path).
* - Deep patches reconstruct path on mutation (O(depth)); no bookkeeping per property upfront.
* - Arrays use `$` (returns array-of-signals proxy) & `$length` (length signal) while skipping function-valued entries.
*
* Performance characteristics:
* - Read of untouched property: ~O(1) creating one signal + potential child proxy.
* - Mutation: signal update + optional patch path build; batching coalesces multiple patches in same microtask.
* - Enumeration tracking: ownKeys/for..in increments a dedicated counter signal (objToIterable) to invalidate dependents.
*/
/**
* deepSignal()
* =====================================================================
* Core idea: Wrap a plain object/array tree in a Proxy that lazily creates
* per-property signals on first access. Property access without a `$` prefix
* returns the underlying value (tracking the read). Access with `$` (e.g. obj.$foo)
* returns the signal function itself. Arrays have special `$` for index signals and
* `$length` for the length signal.
*
* Why function signals? Native alien-signals v2 returns a function you call to read,
* call with an argument to write. We keep that but provide `.value` in tagging layer.
*
* Getter logic summary:
* 1. Determine if the caller wants a signal (prefix `$` or array meta) vs value.
* 2. If property is a getter on the original object, wrap it in a computed signal.
* 3. Otherwise create (once) a writable signal seeded with (possibly proxied) child value.
* 4. Return either the signal function or its invocation (value) depending on caller form.
*
* Setter logic summary:
* - Writes update the raw target via Reflect.set.
* - If the property signal already exists, update it; otherwise create it.
* - Array length & object key enumeration signals are nudged (length / ownKeys tracking).
* - A deep mutation patch is queued capturing: root id, type, path, new value.
*
* Patch batching:
* - Mutations push a patch into a per-microtask buffer (pendingPatches).
* - A queued microtask flush delivers the accumulated array to each subscriber.
* - This enables consumers (framework adapters) to materialize minimal changes.
*
* Metadata chain (proxyMeta): parent + key + root symbol; used to reconstruct full path
* for patches without storing full paths on every node.
*/
/**
* Deep mutation patch system (for Svelte rune integration / granular updates)
* -------------------------------------------------------------------------
* This augmention adds an optional patch stream that reports every deep mutation
* (set/delete) with a property path relative to the root deepSignal() object.
*
* Rationale: The core library already has per-property signals and is efficient
* for effect re-execution. However, consumers that want to mirror the entire
* nested structure into another reactive container (e.g. Svelte $state) gain
* from receiving a batch of fine-grained patches instead of re-cloning.
*
* Design:
* 1. Each proxy created by deepSignal has lightweight metadata (parent, key, root id).
* 2. On each mutation (set/delete) we reconstruct the path by walking parents.
* 3. Patches are batched in a microtask and delivered to subscribers.
* 4. Zero cost for projects that never call subscribeDeepMutations(): only minimal
* metadata storage and O(depth) path build per mutation.
*/
/**
* A granular description of a deep mutation originating from a {@link deepSignal} root.
* Patches are batched per microtask and delivered in order of occurrence.
*
* Invariants:
* - `path` is never empty (the root object itself is not represented by a patch without a key)
* - For `type === "delete"` the `value` field is omitted
* - For `type === "set"` the `value` is the postmutation (proxied if object/array/set) value snapshot
*/
export interface DeepPatch {
/** Unique identifier for the deep signal root which produced this patch. */
root: symbol;
/** Mutation kind applied at the resolved `path`. */
type: "set" | "delete";
/** Property path (array indices, object keys, synthetic Set entry ids) from the root to the mutated location. */
path: (string | number)[];
/** New value for `set` mutations (omitted for `delete`). */
value?: any;
}
/** Function signature for subscribers passed to {@link subscribeDeepMutations}. */
export type DeepPatchSubscriber = (patches: DeepPatch[]) => void;
/**
* Lightweight metadata stored per proxy enabling reconstruction of a property's full path
* at mutation time without eager bookkeeping of every descendant.
*/
interface ProxyMeta {
/** Parent proxy in the object graph (undefined for root). */
parent?: object;
/** Key within the parent pointing to this proxy (undefined for root). */
key?: string | number;
/** Stable root id symbol shared by the entire deepSignal tree. */
root: symbol;
}
/** Internal lookup from proxy -> {@link ProxyMeta}. */
const proxyMeta = new WeakMap<object, ProxyMeta>();
/** Global registry of batch mutation subscribers (filtered per root at delivery time). */
const mutationSubscribers = new Set<DeepPatchSubscriber>();
let pendingPatches: DeepPatch[] | null = null;
let microtaskScheduled = false;
/** Sentinal constant for root id retrieval (exported for external helpers). */
/**
* Sentinel symbol used internally / by helpers to retrieve a deepSignal root id.
* You normally obtain a concrete root id via {@link getDeepSignalRootId} on a proxy instance.
*/
export const DEEP_SIGNAL_ROOT_ID = Symbol("alienDeepSignalRootId");
function buildPath(
startProxy: object,
leafKey: string | number
): (string | number)[] {
const path: (string | number)[] = [leafKey];
let cur: object | undefined = startProxy;
while (cur) {
const meta = proxyMeta.get(cur);
if (!meta) break; // Defensive: metadata should always exist.
if (meta.key === undefined) break; // Reached root (no key recorded).
path.unshift(meta.key);
cur = meta.parent;
}
return path;
}
function queuePatch(patch: DeepPatch) {
if (!pendingPatches) pendingPatches = [];
pendingPatches.push(patch);
if (!microtaskScheduled) {
microtaskScheduled = true;
queueMicrotask(() => {
microtaskScheduled = false;
const batch = pendingPatches;
pendingPatches = null;
if (!batch || batch.length === 0) return;
mutationSubscribers.forEach((sub) => sub(batch));
});
}
}
/**
* Register a mutation batch listener for all active deepSignal roots.
*
* Each microtask in which one or more deep mutations occur produces at most one callback
* invocation containing the ordered list of {@link DeepPatch} objects.
*
* @param sub Callback receiving a batch array (never empty) of deep patches.
* @returns An unsubscribe function that detaches the listener when invoked.
*/
export function subscribeDeepMutations(sub: DeepPatchSubscriber): () => void {
mutationSubscribers.add(sub);
return () => mutationSubscribers.delete(sub);
}
/**
* Obtain the stable root id symbol for a given deepSignal proxy (or any nested proxy).
* Returns `undefined` if the value is not a deepSignal-managed proxy.
*
* @example
* const state = deepSignal({ a: { b: 1 } });
* const id1 = getDeepSignalRootId(state); // symbol
* const id2 = getDeepSignalRootId(state.a); // same symbol as id1
* getDeepSignalRootId({}) // undefined
*/
export function getDeepSignalRootId(obj: any): symbol | undefined {
return proxyMeta.get(obj)?.root;
}
// Proxy -> Map of property name -> signal function
/** Proxy instance -> map of property name -> signal function (created lazily). */
const proxyToSignals = new WeakMap(); const proxyToSignals = new WeakMap();
// Raw object/array/Set -> its stable proxy
const objToProxy = new WeakMap(); const objToProxy = new WeakMap();
// Raw array -> special `$` proxy giving index signals
const arrayToArrayOfSignals = new WeakMap(); const arrayToArrayOfSignals = new WeakMap();
// Objects already proxied or intentionally shallow
const ignore = new WeakSet(); const ignore = new WeakSet();
// Object -> signal counter used for key enumeration invalidation
const objToIterable = new WeakMap(); const objToIterable = new WeakMap();
const rg = /^\$/; const rg = /^\$/;
const descriptor = Object.getOwnPropertyDescriptor; const descriptor = Object.getOwnPropertyDescriptor;
let peeking = false; let peeking = false;
// (Synthetic ID helper declarations were restored further below before usage.)
/**
* Deep array interface expressed as a type intersection to avoid structural extend
* incompatibilities with the native Array while still refining callback parameter
* types to their DeepSignal forms.
*/
type DeepArray<T> = Array<T> & {
map: <U>(
callbackfn: (
value: DeepSignal<T>,
index: number,
array: DeepSignalArray<T[]>
) => U,
thisArg?: any
) => U[];
forEach: (
callbackfn: (
value: DeepSignal<T>,
index: number,
array: DeepSignalArray<T[]>
) => void,
thisArg?: any
) => void;
concat(...items: ConcatArray<T>[]): DeepSignalArray<T[]>;
concat(...items: (T | ConcatArray<T>)[]): DeepSignalArray<T[]>;
reverse(): DeepSignalArray<T[]>;
shift(): DeepSignal<T> | undefined;
slice(start?: number, end?: number): DeepSignalArray<T[]>;
splice(start: number, deleteCount?: number): DeepSignalArray<T[]>;
splice(
start: number,
deleteCount: number,
...items: T[]
): DeepSignalArray<T[]>;
filter<S extends T>(
predicate: (
value: DeepSignal<T>,
index: number,
array: DeepSignalArray<T[]>
) => value is DeepSignal<S>,
thisArg?: any
): DeepSignalArray<S[]>;
filter(
predicate: (
value: DeepSignal<T>,
index: number,
array: DeepSignalArray<T[]>
) => unknown,
thisArg?: any
): DeepSignalArray<T[]>;
reduce(
callbackfn: (
previousValue: DeepSignal<T>,
currentValue: DeepSignal<T>,
currentIndex: number,
array: DeepSignalArray<T[]>
) => T
): DeepSignal<T>;
reduce(
callbackfn: (
previousValue: DeepSignal<T>,
currentValue: DeepSignal<T>,
currentIndex: number,
array: DeepSignalArray<T[]>
) => DeepSignal<T>,
initialValue: T
): DeepSignal<T>;
reduce<U>(
callbackfn: (
previousValue: U,
currentValue: DeepSignal<T>,
currentIndex: number,
array: DeepSignalArray<T[]>
) => U,
initialValue: U
): U;
reduceRight(
callbackfn: (
previousValue: DeepSignal<T>,
currentValue: DeepSignal<T>,
currentIndex: number,
array: DeepSignalArray<T[]>
) => T
): DeepSignal<T>;
reduceRight(
callbackfn: (
previousValue: DeepSignal<T>,
currentValue: DeepSignal<T>,
currentIndex: number,
array: DeepSignalArray<T[]>
) => DeepSignal<T>,
initialValue: T
): DeepSignal<T>;
reduceRight<U>(
callbackfn: (
previousValue: U,
currentValue: DeepSignal<T>,
currentIndex: number,
array: DeepSignalArray<T[]>
) => U,
initialValue: U
): U;
};
// --- Synthetic ID helpers & ergonomics for Set entry patching (restored) ---
let __blankNodeCounter = 0;
/** User or auto-assigned synthetic id bookkeeping for Set entry objects. */
const setObjectIds = new WeakMap<object, string>();
const assignBlankNodeId = (obj: any) => {
if (setObjectIds.has(obj)) return setObjectIds.get(obj)!;
const id = `_b${++__blankNodeCounter}`;
setObjectIds.set(obj, id);
return id;
};
/** Assign (or override) synthetic identifier for an object prior to Set.add(). */
export function setSetEntrySyntheticId(obj: object, id: string | number) {
setObjectIds.set(obj, String(id));
}
const getSetEntryKey = (val: any): string | number => {
if (val && typeof val === "object") {
if (setObjectIds.has(val)) return setObjectIds.get(val)!;
if (
typeof (val as any).id === "string" ||
typeof (val as any).id === "number"
)
return (val as any).id;
if (
typeof (val as any)["@id"] === "string" ||
typeof (val as any)["@id"] === "number"
)
return (val as any)["@id"];
return assignBlankNodeId(val);
}
return val as any;
};
/**
* Insert into a (possibly proxied) Set with a desired synthetic id; returns proxied entry (objects) or primitive.
*/
export function addWithId<T extends object>(
set: Set<T>,
entry: T,
id: string | number
): DeepSignal<T>;
export function addWithId<T>(set: Set<T>, entry: T, id: string | number): T;
export function addWithId(set: Set<any>, entry: any, id: string | number) {
if (entry && typeof entry === "object") setSetEntrySyntheticId(entry, id);
(set as any).add(entry);
if (entry && typeof entry === "object" && objToProxy.has(entry))
return objToProxy.get(entry);
return entry;
}
/** Determine whether a given value is a deepSignal-managed proxy (any depth). */
export const isDeepSignal = (source: any) => { export const isDeepSignal = (source: any) => {
return proxyToSignals.has(source); return proxyToSignals.has(source);
} };
/** Predicate indicating whether a value was explicitly marked via {@link shallow}. */
export const isShallow = (source: any) => { export const isShallow = (source: any) => {
return ignore.has(source) return ignore.has(source);
} };
/**
* Create (or retrieve) a deep reactive proxy for the supplied plain object / array / Set.
* All nested objects / arrays / Sets are wrapped lazily on first access; primitives are passed through.
*
* Root identity: multiple invocations with the same object return the same proxy; each distinct root
* owns a unique symbol id available via {@link getDeepSignalRootId} and present on every emitted patch.
*
*/
export const deepSignal = <T extends object>(obj: T): DeepSignal<T> => { export const deepSignal = <T extends object>(obj: T): DeepSignal<T> => {
if (!shouldProxy(obj)) throw new Error("This object can't be observed."); if (!shouldProxy(obj)) throw new Error("This object can't be observed.");
if (!objToProxy.has(obj)) if (!objToProxy.has(obj)) {
objToProxy.set(obj, createProxy(obj, objectHandlers) as DeepSignal<T>); // Create a unique root id symbol to identify this deep signal tree in patches.
const rootId = Symbol("deepSignalRoot");
const proxy = createProxy(obj, objectHandlers, rootId) as DeepSignal<T>;
const meta = proxyMeta.get(proxy)!;
meta.parent = undefined; // root has no parent
meta.key = undefined; // root not addressed by a key
meta.root = rootId; // ensure root id stored (explicit)
// Pre-register an empty signals map so isDeepSignal() is true before any property access.
if (!proxyToSignals.has(proxy)) proxyToSignals.set(proxy, new Map());
objToProxy.set(obj, proxy);
}
return objToProxy.get(obj); return objToProxy.get(obj);
}; };
/**
* Read a property on a deepSignal proxy without establishing reactive tracking.
* Equivalent conceptually to a non-tracking read / untracked() pattern.
*
* @param obj deepSignal proxy object
* @param key property key to read
* @returns The raw (possibly proxied) property value without dependency collection.
*/
export const peek = < export const peek = <
T extends DeepSignalObject<object>, T extends DeepSignalObject<object>,
K extends keyof RevertDeepSignalObject<T> K extends keyof RevertDeepSignalObject<T>
@ -36,94 +403,293 @@ export const peek = <
const value = obj[key]; const value = obj[key];
try { try {
peeking = false; peeking = false;
} catch (e) { } } catch (e) {}
return value as RevertDeepSignal<RevertDeepSignalObject<T>[K]>; return value as RevertDeepSignal<RevertDeepSignalObject<T>[K]>;
}; };
const shallowFlag = Symbol(ReactiveFlags.IS_SHALLOW); const shallowFlag = Symbol(ReactiveFlags.IS_SHALLOW);
/**
* Mark an object so that it will not be deeply proxied when used as a property value of
* another deepSignal tree. This is a shallow escape hatch for performance or semantic reasons.
*
* NOTE: The returned object itself is not reactive; only top-level assignment of the reference
* produces patches when attached to a deepSignal structure.
*/
export function shallow<T extends object>(obj: T): Shallow<T> { export function shallow<T extends object>(obj: T): Shallow<T> {
ignore.add(obj); ignore.add(obj);
return obj as Shallow<T>; return obj as Shallow<T>;
} }
const createProxy = (target: object, handlers: ProxyHandler<object>) => { const createProxy = (
target: object,
handlers: ProxyHandler<object>,
rootId?: symbol
) => {
const proxy = new Proxy(target, handlers); const proxy = new Proxy(target, handlers);
ignore.add(proxy); ignore.add(proxy);
// Initialize proxy metadata if not present. Root proxies provide a stable root id.
if (!proxyMeta.has(proxy)) {
proxyMeta.set(proxy, { root: rootId || Symbol("deepSignalDetachedRoot") });
}
return proxy; return proxy;
}; };
const throwOnMutation = () => { const throwOnMutation = () => {
throw new Error("Don't mutate the signals directly."); throw new Error(
"Don't mutate the signals directly (use the underlying property/value instead)."
);
}; };
/**
* Unified get trap factory.
* @param isArrayOfSignals indicates we are resolving properties on the special array `$` proxy.
*/
const get = const get =
(isArrayOfSignals: boolean) => (isArrayOfSignals: boolean) =>
(target: object, fullKey: string, receiver: object): unknown => { (target: object, fullKey: string, receiver: object): unknown => {
if (peeking) return Reflect.get(target, fullKey, receiver); if (peeking) return Reflect.get(target, fullKey, receiver);
let returnSignal = isArrayOfSignals || fullKey[0] === "$"; let returnSignal = isArrayOfSignals || fullKey[0] === "$";
if (!isArrayOfSignals && returnSignal && Array.isArray(target)) { // Special handling for Set instances: treat as atomic & emit structural + per-entry patches
if (fullKey === "$") { if (target instanceof Set && typeof fullKey === "string") {
if (!arrayToArrayOfSignals.has(target)) const raw = target as Set<any>;
arrayToArrayOfSignals.set(target, createProxy(target, arrayHandlers)); const key = fullKey;
return arrayToArrayOfSignals.get(target); const meta = proxyMeta.get(receiver);
// Helper to proxy a single entry
const ensureEntryProxy = (entry: any) => {
if (
entry &&
typeof entry === "object" &&
shouldProxy(entry) &&
!objToProxy.has(entry)
) {
const synthetic = getSetEntryKey(entry);
const childProxy = createProxy(entry, objectHandlers, meta!.root);
const childMeta = proxyMeta.get(childProxy)!;
childMeta.parent = receiver;
childMeta.key = synthetic;
objToProxy.set(entry, childProxy);
return childProxy;
} }
returnSignal = fullKey === "$length"; if (objToProxy.has(entry)) return objToProxy.get(entry);
return entry;
};
// Pre-pass to ensure any existing non-proxied object entries are proxied
if (meta) raw.forEach(ensureEntryProxy);
if (key === "add" || key === "delete" || key === "clear") {
const fn: Function = (raw as any)[key];
return function (this: any, ...args: any[]) {
const sizeBefore = raw.size;
const result = fn.apply(raw, args);
if (raw.size !== sizeBefore) {
const metaNow = proxyMeta.get(receiver);
if (
metaNow &&
metaNow.parent !== undefined &&
metaNow.key !== undefined
) {
const containerPath = buildPath(metaNow.parent, metaNow.key);
if (key === "add") {
const entry = args[0];
let synthetic = getSetEntryKey(entry);
if (entry && typeof entry === "object") {
for (const existing of raw.values()) {
if (existing === entry) continue;
if (getSetEntryKey(existing) === synthetic) {
synthetic = assignBlankNodeId(entry);
break;
}
}
}
let entryVal = entry;
if (
entryVal &&
typeof entryVal === "object" &&
shouldProxy(entryVal) &&
!objToProxy.has(entryVal)
) {
const childProxy = createProxy(
entryVal,
objectHandlers,
metaNow.root
);
const childMeta = proxyMeta.get(childProxy)!;
childMeta.parent = receiver;
childMeta.key = synthetic;
objToProxy.set(entryVal, childProxy);
entryVal = childProxy;
}
queuePatch({
root: metaNow.root,
type: "set",
path: [...containerPath, synthetic],
value: entryVal,
});
} else if (key === "delete") {
const entry = args[0];
const synthetic = getSetEntryKey(entry);
queuePatch({
root: metaNow.root,
type: "delete",
path: [...containerPath, synthetic],
});
} else if (key === "clear") {
queuePatch({
root: metaNow.root,
type: "set",
path: containerPath,
value: raw,
});
}
}
}
return result;
};
} }
if (!proxyToSignals.has(receiver)) proxyToSignals.set(receiver, new Map()); const makeIterator = (pair: boolean) => {
const signals = proxyToSignals.get(receiver); return function thisIter(this: any) {
const key = returnSignal ? fullKey.replace(rg, "") : fullKey; const iterable = raw.values();
if ( return {
!signals.has(key) && [Symbol.iterator]() {
typeof descriptor(target, key)?.get === "function" return {
) { next() {
signals.set( const n = iterable.next();
key, if (n.done) return n;
computed(() => Reflect.get(target, key, receiver)) const entry = ensureEntryProxy(n.value);
); return { value: pair ? [entry, entry] : entry, done: false };
} else { },
let value = Reflect.get(target, key, receiver); };
if (returnSignal && typeof value === "function") return; },
if (typeof key === "symbol" && wellKnownSymbols.has(key)) return value; } as Iterable<any>;
if (!signals.has(key)) { };
if (shouldProxy(value)) { };
if (!objToProxy.has(value)) if (key === "values" || key === "keys") return makeIterator(false);
objToProxy.set(value, createProxy(value, objectHandlers)); if (key === "entries") return makeIterator(true);
value = objToProxy.get(value); if (key === "forEach") {
return function thisForEach(this: any, cb: any, thisArg?: any) {
raw.forEach((entry: any) => {
cb.call(
thisArg,
ensureEntryProxy(entry),
ensureEntryProxy(entry),
raw
);
});
};
}
if (key === Symbol.iterator.toString()) {
// string form access of iterator symbol; pass through
}
const val = Reflect.get(raw, key, raw);
if (typeof val === "function") return val.bind(raw);
return val;
}
if (!isArrayOfSignals && returnSignal && Array.isArray(target)) {
if (fullKey === "$") {
if (!arrayToArrayOfSignals.has(target))
arrayToArrayOfSignals.set(
target,
createProxy(
target,
arrayHandlers,
proxyMeta.get(receiver)?.root // propagate root id to $ array proxy
)
);
return arrayToArrayOfSignals.get(target);
}
returnSignal = fullKey === "$length";
}
if (!proxyToSignals.has(receiver)) proxyToSignals.set(receiver, new Map()); // allocate map lazily
const signals = proxyToSignals.get(receiver);
const key = returnSignal ? fullKey.replace(rg, "") : fullKey;
if (
!signals.has(key) &&
typeof descriptor(target, key)?.get === "function"
) {
signals.set(
key,
computed(() => Reflect.get(target, key, receiver))
);
} else {
let value = Reflect.get(target, key, receiver);
if (returnSignal && typeof value === "function") return; // functions never wrapped as signals
if (typeof key === "symbol" && wellKnownSymbols.has(key)) return value;
if (!signals.has(key)) {
if (shouldProxy(value)) {
if (!objToProxy.has(value)) {
// Child object/array lazily wrapped: link to parent for path reconstruction.
const parentMeta = proxyMeta.get(receiver)!;
const childProxy = createProxy(
value,
objectHandlers,
parentMeta.root
);
const childMeta = proxyMeta.get(childProxy)!;
childMeta.parent = receiver;
childMeta.key = key as string;
objToProxy.set(value, childProxy);
} }
signals.set(key, signal(value)); value = objToProxy.get(value);
} }
signals.set(key, signal(value));
} }
// deep getter }
return returnSignal ? signals.get(key) : signals.get(key).get(); // deep getter: function signals are callable; non-signal access returns current value.
}; // We intentionally return the raw function (signal) when `$`-prefixed so callers can set `.value` or invoke.
const sig = signals.get(key);
return returnSignal ? sig : sig();
};
// Handlers for standard object/array (non `$` array meta proxy)
const objectHandlers = { const objectHandlers = {
get: get(false), get: get(false),
set(target: object, fullKey: string, val: any, receiver: object): boolean { set(target: object, fullKey: string, val: any, receiver: object): boolean {
// Respect original getter/setter semantics
if (typeof descriptor(target, fullKey)?.set === "function") if (typeof descriptor(target, fullKey)?.set === "function")
return Reflect.set(target, fullKey, val, receiver); return Reflect.set(target, fullKey, val, receiver);
if (!proxyToSignals.has(receiver)) proxyToSignals.set(receiver, new Map()); if (!proxyToSignals.has(receiver)) proxyToSignals.set(receiver, new Map());
const signals = proxyToSignals.get(receiver); const signals = proxyToSignals.get(receiver);
if (fullKey[0] === "$") { if (fullKey[0] === "$") {
if (!(val instanceof Signal)) throwOnMutation(); if (!isSignal(val)) throwOnMutation();
const key = fullKey.replace(rg, ""); const key = fullKey.replace(rg, "");
signals.set(key, val); signals.set(key, val);
return Reflect.set(target, key, val.peek(), receiver); return Reflect.set(target, key, val.peek(), receiver);
} else { } else {
let internal = val; let internal = val;
if (shouldProxy(val)) { if (shouldProxy(val)) {
if (!objToProxy.has(val)) if (!objToProxy.has(val)) {
objToProxy.set(val, createProxy(val, objectHandlers)); // Link newly wrapped child to its parent for path reconstruction.
const parentMeta = proxyMeta.get(receiver)!;
const childProxy = createProxy(val, objectHandlers, parentMeta.root);
const childMeta = proxyMeta.get(childProxy)!;
childMeta.parent = receiver;
childMeta.key = fullKey;
objToProxy.set(val, childProxy);
}
internal = objToProxy.get(val); internal = objToProxy.get(val);
} }
const isNew = !(fullKey in target); const isNew = !(fullKey in target);
const result = Reflect.set(target, fullKey, val, receiver); const result = Reflect.set(target, fullKey, val, receiver);
if (!signals.has(fullKey)) signals.set(fullKey, signal(internal)); if (!signals.has(fullKey)) {
else signals.get(fullKey).set(internal); // First write after structure change -> create signal.
signals.set(fullKey, signal(internal));
} else {
// Subsequent writes -> update underlying signal.
signals.get(fullKey).set(internal);
}
if (isNew && objToIterable.has(target)) objToIterable.get(target).value++; if (isNew && objToIterable.has(target)) objToIterable.get(target).value++;
if (Array.isArray(target) && signals.has("length")) if (Array.isArray(target) && signals.has("length"))
signals.get("length").set(target.length); signals.get("length").set(target.length);
// Emit patch (after mutation) so subscribers get final value snapshot.
const meta = proxyMeta.get(receiver);
if (meta) {
queuePatch({
root: meta.root,
type: "set",
path: buildPath(receiver, fullKey),
value: val,
});
}
return result; return result;
} }
}, },
@ -133,6 +699,16 @@ const objectHandlers = {
const result = Reflect.deleteProperty(target, key); const result = Reflect.deleteProperty(target, key);
if (signals && signals.has(key)) signals.get(key).value = undefined; if (signals && signals.has(key)) signals.get(key).value = undefined;
objToIterable.has(target) && objToIterable.get(target).value++; objToIterable.has(target) && objToIterable.get(target).value++;
// Emit deletion patch
const receiverProxy = objToProxy.get(target);
const meta = receiverProxy && proxyMeta.get(receiverProxy);
if (meta) {
queuePatch({
root: meta.root,
type: "delete",
path: buildPath(receiverProxy, key),
});
}
return result; return result;
}, },
ownKeys(target: object): (string | symbol)[] { ownKeys(target: object): (string | symbol)[] {
@ -142,6 +718,7 @@ const objectHandlers = {
}, },
}; };
// Handlers for special `$` proxy wrapping an array (index signals only)
const arrayHandlers = { const arrayHandlers = {
get: get(true), get: get(true),
set: throwOnMutation, set: throwOnMutation,
@ -150,10 +727,11 @@ const arrayHandlers = {
const wellKnownSymbols = new Set( const wellKnownSymbols = new Set(
Object.getOwnPropertyNames(Symbol) Object.getOwnPropertyNames(Symbol)
.map(key => Symbol[key as WellKnownSymbols]) .map((key) => Symbol[key as WellKnownSymbols])
.filter(value => typeof value === "symbol") .filter((value) => typeof value === "symbol")
); );
const supported = new Set([Object, Array]); // Support Set so structural mutations can emit patches (Map still unsupported for now)
const supported = new Set([Object, Array, Set]);
const shouldProxy = (val: any): boolean => { const shouldProxy = (val: any): boolean => {
if (typeof val !== "object" || val === null) return false; if (typeof val !== "object" || val === null) return false;
return supported.has(val.constructor) && !ignore.has(val); return supported.has(val.constructor) && !ignore.has(val);
@ -161,6 +739,11 @@ const shouldProxy = (val: any): boolean => {
/** TYPES **/ /** TYPES **/
/**
* Structural deep reactive view of an input type. Functions and values marked with {@link Shallow}
* are passed through untouched; arrays and plain objects become recursively deep-signal aware;
* Sets are proxied so structural & deep entry mutations emit patches.
*/
export type DeepSignal<T> = T extends Function export type DeepSignal<T> = T extends Function
? T ? T
: T extends { [shallowFlag]: true } : T extends { [shallowFlag]: true }
@ -171,133 +754,45 @@ export type DeepSignal<T> = T extends Function
? DeepSignalObject<T> ? DeepSignalObject<T>
: T; : T;
type DeepSignalObject<T extends object> = { /** Recursive mapped type converting an object graph into its deepSignal proxy shape. */
export type DeepSignalObject<T extends object> = {
[P in keyof T & string as `$${P}`]?: T[P] extends Function [P in keyof T & string as `$${P}`]?: T[P] extends Function
? never ? never
: Signal<T[P]>; : ReturnType<typeof signal<T[P]>>;
} & { } & {
[P in keyof T]: DeepSignal<T[P]>; [P in keyof T]: DeepSignal<T[P]>;
}; };
/** @ts-expect-error **/ /** Extract element type from an array. */
interface DeepArray<T> extends Array<T> {
map: <U>(
callbackfn: (
value: DeepSignal<T>,
index: number,
array: DeepSignalArray<T[]>
) => U,
thisArg?: any
) => U[];
forEach: (
callbackfn: (
value: DeepSignal<T>,
index: number,
array: DeepSignalArray<T[]>
) => void,
thisArg?: any
) => void;
concat(...items: ConcatArray<T>[]): DeepSignalArray<T[]>;
concat(...items: (T | ConcatArray<T>)[]): DeepSignalArray<T[]>;
reverse(): DeepSignalArray<T[]>;
shift(): DeepSignal<T> | undefined;
slice(start?: number, end?: number): DeepSignalArray<T[]>;
splice(start: number, deleteCount?: number): DeepSignalArray<T[]>;
splice(
start: number,
deleteCount: number,
...items: T[]
): DeepSignalArray<T[]>;
filter<S extends T>(
predicate: (
value: DeepSignal<T>,
index: number,
array: DeepSignalArray<T[]>
) => value is DeepSignal<S>,
thisArg?: any
): DeepSignalArray<S[]>;
filter(
predicate: (
value: DeepSignal<T>,
index: number,
array: DeepSignalArray<T[]>
) => unknown,
thisArg?: any
): DeepSignalArray<T[]>;
reduce(
callbackfn: (
previousValue: DeepSignal<T>,
currentValue: DeepSignal<T>,
currentIndex: number,
array: DeepSignalArray<T[]>
) => T
): DeepSignal<T>;
reduce(
callbackfn: (
previousValue: DeepSignal<T>,
currentValue: DeepSignal<T>,
currentIndex: number,
array: DeepSignalArray<T[]>
) => DeepSignal<T>,
initialValue: T
): DeepSignal<T>;
reduce<U>(
callbackfn: (
previousValue: U,
currentValue: DeepSignal<T>,
currentIndex: number,
array: DeepSignalArray<T[]>
) => U,
initialValue: U
): U;
reduceRight(
callbackfn: (
previousValue: DeepSignal<T>,
currentValue: DeepSignal<T>,
currentIndex: number,
array: DeepSignalArray<T[]>
) => T
): DeepSignal<T>;
reduceRight(
callbackfn: (
previousValue: DeepSignal<T>,
currentValue: DeepSignal<T>,
currentIndex: number,
array: DeepSignalArray<T[]>
) => DeepSignal<T>,
initialValue: T
): DeepSignal<T>;
reduceRight<U>(
callbackfn: (
previousValue: U,
currentValue: DeepSignal<T>,
currentIndex: number,
array: DeepSignalArray<T[]>
) => U,
initialValue: U
): U;
}
type ArrayType<T> = T extends Array<infer I> ? I : T; type ArrayType<T> = T extends Array<infer I> ? I : T;
/** DeepSignal-enhanced array type (numeric indices & `$` meta accessors). */
type DeepSignalArray<T> = DeepArray<ArrayType<T>> & { type DeepSignalArray<T> = DeepArray<ArrayType<T>> & {
[key: number]: DeepSignal<ArrayType<T>>; [key: number]: DeepSignal<ArrayType<T>>;
$?: { [key: number]: Signal<ArrayType<T>> }; $?: { [key: number]: ReturnType<typeof signal<ArrayType<T>>> };
$length?: Signal<number>; $length?: ReturnType<typeof signal<number>>;
}; };
/** Marker utility type for objects passed through without deep proxying. */
export type Shallow<T extends object> = T & { [shallowFlag]: true }; export type Shallow<T extends object> = T & { [shallowFlag]: true };
/** Framework adapter hook (declared for consumers) that returns a {@link DeepSignal} proxy. */
export declare const useDeepSignal: <T extends object>(obj: T) => DeepSignal<T>; export declare const useDeepSignal: <T extends object>(obj: T) => DeepSignal<T>;
// @ts-ignore // @ts-ignore
type FilterSignals<K> = K extends `$${infer P}` ? never : K; /** Utility: strip `$`-prefixed synthetic signal accessors from key union. */
type FilterSignals<K> = K extends `$${string}` ? never : K;
/** Reverse of {@link DeepSignalObject}: remove signal accessors to obtain original object shape. */
type RevertDeepSignalObject<T> = Pick<T, FilterSignals<keyof T>>; type RevertDeepSignalObject<T> = Pick<T, FilterSignals<keyof T>>;
/** Reverse of {@link DeepSignalArray}: omit meta accessors. */
type RevertDeepSignalArray<T> = Omit<T, "$" | "$length">; type RevertDeepSignalArray<T> = Omit<T, "$" | "$length">;
/** Inverse mapped type that removes deepSignal wrapper affordances (`$` accessors). */
export type RevertDeepSignal<T> = T extends Array<unknown> export type RevertDeepSignal<T> = T extends Array<unknown>
? RevertDeepSignalArray<T> ? RevertDeepSignalArray<T>
: T extends object : T extends object
? RevertDeepSignalObject<T> ? RevertDeepSignalObject<T>
: T; : T;
/** Subset of ECMAScript well-known symbols we explicitly pass through without proxy wrapping. */
type WellKnownSymbols = type WellKnownSymbols =
| "asyncIterator" | "asyncIterator"
| "hasInstance" | "hasInstance"

@ -1,7 +1,7 @@
export * from "./core"; export * from "./core";
export * from "./deepSignal"; export * from "./deepSignal";
export * from "./watch" export * from "./watch";
export * from "./watchEffect" export * from "./watchEffect";
export { export {
isArray, isArray,
isDate, isDate,
@ -14,4 +14,4 @@ export {
isSet, isSet,
isString, isString,
isSymbol, isSymbol,
} from "./utils" } from "./utils";

@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { signal, computed, isSignal, Effect, toValue } from "../core";
import { deepSignal } from "../deepSignal";
describe("core.ts coverage", () => {
it("signal tagging helpers (.value/.peek/.get/.set)", () => {
const s: any = signal(1);
expect(isSignal(s)).toBe(true);
expect(s.value).toBe(1);
expect(s.peek()).toBe(1);
expect(s.get()).toBe(1);
s.set(2);
expect(s.value).toBe(2);
s.value = 3;
expect(s.peek()).toBe(3);
});
it("computed tagging helpers (.value/.peek/.get)", () => {
const s: any = signal(2);
const c: any = computed(() => s.value * 2);
expect(isSignal(c)).toBe(true);
expect(c.value).toBe(4);
expect(c.peek()).toBe(4);
expect(c.get()).toBe(4);
s.value = 3;
expect(c.value).toBe(6);
});
it("toValue resolves function, signal and plain value", () => {
const s: any = signal(5);
const fn = () => 10;
expect(toValue(fn)).toBe(10);
expect(toValue(s)).toBe(5);
expect(toValue(42)).toBe(42);
});
it("Effect wrapper run/stop behavior", () => {
let runs = 0;
const eff = new Effect(() => {
runs++;
});
// Constructing Effect registers alienEffect and schedules first run immediately when dependency accessed (none here), run() executes getter
eff.run();
// Construction may trigger an initial scheduler pass; ensure at least 1
expect(runs).toBeGreaterThanOrEqual(1);
// Add scheduler side effect and dependency in second effect
const dep = signal(0);
const eff2 = new Effect(() => {
dep();
runs++;
});
const base = runs;
dep.set(1); // triggers wrapped effect, increments runs again
expect(runs).toBeGreaterThan(base);
eff2.stop();
const prev = runs;
dep.set(2); // no further increment after stop
expect(runs).toBe(prev);
// stopping already stopped effect has no effect
eff2.stop();
expect(runs).toBe(prev);
});
});
describe("deepSignal.ts extra branches", () => {
it("access well-known symbol property returns raw value and not a signal", () => {
const tag = Symbol.toStringTag;
const ds = deepSignal({ [tag]: "Custom", x: 1 }) as any;
const val = ds[tag];
expect(val).toBe("Custom");
});
it("access Set Symbol.iterator.toString() key path (skip branch)", () => {
const ds = deepSignal({ set: new Set([1]) }) as any;
const iterKey = Symbol.iterator.toString(); // 'Symbol(Symbol.iterator)'
// Accessing this string property triggers skip branch (no special handling needed)
const maybe = ds.set[iterKey];
// underlying Set likely has undefined for that string key
expect(maybe).toBeUndefined();
});
});

File diff suppressed because it is too large Load Diff

@ -0,0 +1,76 @@
import { describe, it, expect, beforeEach } from "vitest";
import { deepSignal } from "../deepSignal";
import { watch, __traverseCount, __resetTraverseCount } from "../watch";
// Goal: demonstrate that patchOptimized deep watch performs fewer traversals
// than standard deep watch for the same batch of nested mutations.
// We use the exported __traverseCount instrumentation to measure how many
// times traverse() executes under each strategy.
describe("watch patchOptimized performance", () => {
let store: any;
const build = (breadth = 3, depth = 3) => {
const make = (d: number): any => {
if (d === 0) return { v: 0 };
const obj: any = {};
for (let i = 0; i < breadth; i++) obj["k" + i] = make(d - 1);
return obj;
};
return make(depth);
};
beforeEach(() => {
store = deepSignal(build());
});
function mutateAll(breadth = 3, depth = 3) {
const visit = (node: any, d: number) => {
if (d === 0) {
node.v++;
return;
}
for (let i = 0; i < breadth; i++) visit(node["k" + i], d - 1);
};
visit(store, depth);
}
it("reduces traverse calls for deep watchers", async () => {
// Non-optimized deep watch
__resetTraverseCount();
const stop1 = watch(
store,
() => {
/* no-op */
},
{ deep: true, patchOptimized: false }
);
mutateAll();
await Promise.resolve();
await Promise.resolve();
const traversalsNormal = __traverseCount;
stop1();
// Optimized deep watch
__resetTraverseCount();
const stop2 = watch(
store,
() => {
/* no-op */
},
{ deep: true, patchOptimized: true }
);
mutateAll();
await Promise.resolve();
await Promise.resolve();
const traversalsOptimized = __traverseCount;
stop2();
console.log(
`Traversals normal: ${traversalsNormal}, optimized: ${traversalsOptimized}`
);
// Optimized path should not perform more traversals than baseline and ideally fewer.
expect(traversalsOptimized <= traversalsNormal).toBe(true);
// Ensure baseline actually did at least one traversal more than optimized (sanity check)
expect(traversalsNormal > traversalsOptimized).toBe(true);
});
});

@ -0,0 +1,199 @@
import { describe, it, expect } from "vitest";
import { deepSignal, getDeepSignalRootId, peek } from "../deepSignal";
import {
watch,
watchPatches,
observe,
__traverseCount,
__resetTraverseCount,
traverse,
} from "../watch";
import { effect, signal } from "../core";
describe("watch advanced", () => {
it("numeric deep depth limits traversal reactions", async () => {
const st = deepSignal({ a: { b: { c: 1 } } });
let runs = 0;
watch(
st,
() => {
runs++;
},
{ deep: 1 }
); // depth 1: should collect a & a.b but not a.b.c
// initial run (immediate not set so first collection sets oldValue only)
st.a.b.c = 2; // depth 2 change (may trigger depending on traversal semantics)
st.a.b = { c: 3 }; // depth 1 mutation
await Promise.resolve();
await Promise.resolve();
expect(runs).toBeGreaterThan(0);
});
it("multi-source watch array triggers when one source changes", async () => {
const a = signal(1);
const b = deepSignal({ x: 1 });
let vals: any[] = [];
watch(
[a, b],
(nv) => {
vals = nv;
},
{ deep: true }
);
b.x = 2;
await Promise.resolve();
await Promise.resolve();
expect(vals[1].x).toBe(2);
});
it("watch getter source (function) with callback", async () => {
const st = deepSignal({ n: 1 });
let seen = 0;
watch(
() => st.n * 2,
(val) => {
seen = val;
},
{ immediate: true }
);
expect(seen).toBe(2);
st.n = 2;
await Promise.resolve();
await Promise.resolve();
expect(seen).toBe(4);
});
it("watch once with patchOptimized deep on deepSignal", async () => {
const st = deepSignal({ a: 1 });
let count = 0;
watch(
st,
() => {
count++;
},
{ deep: true, once: true, patchOptimized: true }
);
st.a = 2;
st.a = 3;
await Promise.resolve();
await Promise.resolve();
expect(count).toBe(1);
});
it("observe value mode returns values and not patches", async () => {
const st = deepSignal({ a: 1 });
let latest: any;
const stop = observe(
st,
(v: any) => {
latest = v;
},
{ deep: true }
);
st.a = 2;
await Promise.resolve();
await Promise.resolve();
expect(latest.a).toBe(2);
stop();
});
});
describe("patches & root ids", () => {
it("root ids are unique", () => {
const a = deepSignal({});
const b = deepSignal({});
expect(getDeepSignalRootId(a)).not.toBe(getDeepSignalRootId(b));
});
it("watchPatches throws on non-deepSignal input", () => {
expect(() => watchPatches({}, () => {})).toThrow();
});
it("Map unsupported does not emit patches", async () => {
const m = new Map<string, number>();
const st = deepSignal({ m });
const patches: any[] = [];
const stop = watchPatches(st, (p) => patches.push(p));
m.set("a", 1);
await Promise.resolve();
await Promise.resolve();
expect(patches.length).toBe(0);
stop();
});
});
describe("tier3: Set iteration variants", () => {
it("entries() iteration proxies nested mutation", async () => {
const st = deepSignal({ s: new Set<any>() });
st.s.add({ id: "eEnt", inner: { v: 1 } });
const paths: string[] = [];
const stop = watchPatches(st, (p) =>
paths.push(...p.map((pp) => pp.path.join(".")))
);
for (const [val] of st.s.entries()) {
(val as any).inner.v;
} // ensure proxy
for (const [val] of st.s.entries()) {
(val as any).inner.v = 2;
}
await Promise.resolve();
await Promise.resolve();
expect(paths.some((p) => p.endsWith("eEnt.inner.v"))).toBe(true);
stop();
});
it("forEach iteration proxies nested mutation", async () => {
const st = deepSignal({ s: new Set<any>() });
st.s.add({ id: "fe1", data: { n: 1 } });
const stop = watchPatches(st, () => {});
st.s.forEach((e) => (e as any).data.n); // access
st.s.forEach((e) => {
(e as any).data.n = 2;
});
await Promise.resolve();
await Promise.resolve();
stop();
});
it("keys() iteration returns proxies", async () => {
const st = deepSignal({ s: new Set<any>() });
st.s.add({ id: "k1", foo: { x: 1 } });
const stop = watchPatches(st, () => {});
for (const e of st.s.keys()) {
(e as any).foo.x = 2;
}
await Promise.resolve();
await Promise.resolve();
stop();
});
});
describe("tier3: peek behavior", () => {
it("peek does not create reactive dependency on property", async () => {
const st = deepSignal({ a: 1 });
let runs = 0;
effect(() => {
runs++;
peek(st, "a");
});
expect(runs).toBe(1);
st.a = 2;
// Flush microtasks
await Promise.resolve();
await Promise.resolve();
expect(runs).toBe(1); // no rerun
});
});
describe("tier3: traverse helper direct calls (symbols & sets)", () => {
it("traverse counts and respects depth param", () => {
__resetTraverseCount();
const obj: any = { a: { b: { c: 1 } } };
traverse(obj, 1);
const shallowCount = __traverseCount;
__resetTraverseCount();
traverse(obj, 3);
const deepCount = __traverseCount;
expect(deepCount).toBeGreaterThan(shallowCount);
});
});

@ -3,79 +3,96 @@ import { deepSignal } from "../deepSignal";
import { watch } from "../watch"; import { watch } from "../watch";
import { watchEffect } from "../watchEffect"; import { watchEffect } from "../watchEffect";
describe('watch', () => { describe("watch", () => {
it('watch immediate', () => { it("watch immediate", () => {
const store = deepSignal({ const store = deepSignal({
userinfo: { userinfo: {
name: "tom" name: "tom",
},
});
let val!: string;
watch(
store,
(newValue) => {
val = newValue.userinfo.name;
},
{
immediate: true,
deep: true,
} }
}) );
let val!: string expect(val).toEqual("tom");
watch(store, (newValue) => { });
val = newValue.userinfo.name it("watch deep", () => {
}, {
immediate: true,
deep: true
})
expect(val).toEqual('tom')
})
it('watch deep', () => {
const store = deepSignal({ const store = deepSignal({
userinfo: { userinfo: {
name: "tom" name: "tom",
},
});
let val!: string;
watch(
store,
(newValue) => {
val = newValue.userinfo.name;
},
{
immediate: true,
deep: true,
} }
}) );
let val!: string let value2!: string;
watch(store, (newValue) => { watch(
val = newValue.userinfo.name store,
}, { (newValue) => {
immediate: true, value2 = newValue.userinfo.name;
deep: true },
}) { immediate: true }
let value2!: string );
watch(store, (newValue) => { expect(val).toEqual("tom");
value2 = newValue.userinfo.name store.userinfo.name = "jon";
}, { immediate: true }) expect(val).toEqual("jon");
expect(val).toEqual('tom') // With refactored watch using native effect, shallow watcher now also updates root reference
store.userinfo.name = "jon" expect(value2).toEqual("jon");
expect(val).toEqual('jon') });
expect(value2).toEqual('tom')
})
it('watch once', () => { it("watch once", () => {
const store = deepSignal({ const store = deepSignal({
userinfo: { userinfo: {
name: "tom" name: "tom",
},
});
let val!: string;
watch(
store,
(newValue) => {
val = newValue.userinfo.name;
},
{
immediate: true,
deep: true,
once: true,
} }
}) );
let val!: string
watch(store, (newValue) => {
val = newValue.userinfo.name
}, {
immediate: true,
deep: true,
once: true
})
expect(val).toEqual("tom") expect(val).toEqual("tom");
store.userinfo.name = "jon" store.userinfo.name = "jon";
expect(val).not.toEqual("jon") // once watcher shouldn't update after first run
expect(val).toEqual("tom") expect(val).toEqual("tom");
}) });
it('watch effect', () => { it("watch effect", () => {
const store = deepSignal({ const store = deepSignal({
userinfo: { userinfo: {
name: "tom" name: "tom",
} },
}) });
let x = undefined let x = undefined;
watchEffect(() => { watchEffect(() => {
x = store.userinfo.name x = store.userinfo.name;
}) });
expect(x).toEqual("tom") expect(x).toEqual("tom");
store.userinfo.name = "jon" store.userinfo.name = "jon";
expect(x).toEqual("jon") expect(x).toEqual("jon");
}) });
}) });

@ -0,0 +1,339 @@
import { describe, it, expect } from "vitest";
import { deepSignal, setSetEntrySyntheticId, addWithId } from "../deepSignal";
import { watchPatches, observe, Patch } from "../watch";
/**
* Tests for watchPatches / observe(..., {mode:'patch'}) ensuring:
* 1. Only patches from the provided root are emitted.
* 2. Batching groups multiple sync mutations in one array.
* 3. Delete operations are reported without value.
* 4. observe patch mode mirrors watchPatches output.
*/
// NOTE about Set entry tests:
// deepSignal does NOT rewrite external references passed into Set.add().
// After an object is added, mutate ONLY the proxied version (obtained via iteration, values(), entries(), forEach, or addWithId) to get deep patches.
// Mutating the original variable captured before adding will NOT emit patches.
describe("watchPatches", () => {
it("emits set patches with correct paths and batching", async () => {
const state = deepSignal({ a: { b: 1 }, arr: [1, { x: 2 }] });
const received: any[] = [];
const stop = watchPatches(state, (patches) => {
received.push(patches);
});
// multiple synchronous mutations => single microtask batch expected
state.a.b = 2;
(state.arr[1] as any).x = 3;
state.arr.push(5);
await Promise.resolve(); // flush microtask
expect(received.length).toBe(1);
const batch = received[0];
// Paths should reflect root-relative keys
const paths = (batch as Patch[]).map((p: Patch) => p.path.join(".")).sort();
expect(paths).toContain("a.b");
expect(paths).toContain("arr.1.x");
expect(paths).toContain("arr.2"); // new index push
// Types
const setTypes = (batch as Patch[]).filter(
(p: Patch) => p.type === "set"
).length;
expect(setTypes).toBe(batch.length);
stop();
});
it("emits delete patches without value", async () => {
const state = deepSignal<{ a: { b?: number }; c?: number }>({
a: { b: 1 },
c: 2,
});
const out: any[] = [];
const stop = watchPatches(state, (p) => out.push(p));
delete state.a.b;
delete state.c;
await Promise.resolve();
expect(out.length).toBe(1);
const [batch] = out;
const deletePatches = (batch as Patch[]).filter(
(p: Patch) => p.type === "delete"
);
const delPaths = deletePatches.map((p: Patch) => p.path.join(".")).sort();
expect(delPaths).toEqual(["a.b", "c"]);
deletePatches.forEach((p: Patch) => expect(p.value).toBeUndefined());
stop();
});
it("observe patch mode mirrors watchPatches", async () => {
const state = deepSignal({ a: 1 });
const wp: any[] = [];
const ob: any[] = [];
const stop1 = watchPatches(state, (p) => wp.push(p));
const stop2 = observe(state, (p: Patch[]) => ob.push(p), { mode: "patch" });
state.a = 2;
await Promise.resolve();
expect(wp.length).toBe(1);
expect(ob.length).toBe(1);
expect(wp[0].length).toBe(1);
expect(ob[0][0].path.join(".")).toBe("a");
stop1();
stop2();
});
it("filters out patches from other roots", async () => {
const a = deepSignal({ x: 1 });
const b = deepSignal({ y: 2 });
const out: any[] = [];
const stop = watchPatches(a, (p) => out.push(p));
b.y = 3; // unrelated root
a.x = 2; // related root
await Promise.resolve();
expect(out.length).toBe(1);
expect(out[0][0].path.join(".")).toBe("x");
stop();
});
it("emits patches for Set structural mutations (add/delete)", async () => {
const state = deepSignal<{ s: Set<number> }>({ s: new Set([1, 2]) });
const batches: Patch[][] = [];
const stop = watchPatches(state, (p) => batches.push(p));
state.s.add(3);
state.s.delete(1);
await Promise.resolve();
expect(batches.length >= 1).toBe(true);
const allPaths = batches.flatMap((b) => b.map((p) => p.path.join(".")));
// Expect per-entry paths like s.3 (primitive) or s.<synthetic>
expect(allPaths.some((p) => p.startsWith("s."))).toBe(true);
stop();
});
it("emits patches for nested objects added after initialization", async () => {
const state = deepSignal<{ root: any }>({ root: {} });
const patches: Patch[][] = [];
const stop = watchPatches(state, (p) => patches.push(p));
state.root.child = { level: { value: 1 } };
state.root.child.level.value = 2;
await Promise.resolve();
const flat = patches.flat();
const paths = flat.map((p) => p.path.join("."));
expect(paths).toContain("root.child"); // initial add
expect(paths).toContain("root.child.level.value"); // deep mutation
stop();
});
it("emits structural patches for sets of sets (no deep inner object mutation tracking)", async () => {
const innerA = new Set<any>([{ id: "node1", x: 1 }]);
const s = new Set<any>([innerA]);
const state = deepSignal<{ graph: Set<any> }>({ graph: s });
const batches: Patch[][] = [];
const stop = watchPatches(state, (p) => batches.push(p));
// Add a new inner set
const innerB = new Set<any>([{ id: "node2", x: 5 }]);
state.graph.add(innerB);
// Mutate object inside innerA
([...innerA][0] as any).x = 2;
await Promise.resolve();
const flat = batches.flat();
const pathStrings = flat.map((p) => p.path.join("."));
// Expect a patch for adding innerB (graph.<syntheticId>)
expect(pathStrings.some((p) => p.startsWith("graph."))).toBe(true);
stop();
});
it("tracks deep nested object mutation inside a Set entry after iteration", async () => {
const rawEntry = { id: "n1", data: { val: 1 } };
const st = deepSignal({ bag: new Set<any>([rawEntry]) });
const patches: Patch[][] = [];
const stop = watchPatches(st, (p) => patches.push(p));
// Obtain proxied entry via iteration
let proxied: any;
for (const e of st.bag.values()) {
proxied = e; // this is the proxied version
e.data.val; // access to ensure deep proxying of nested object
}
// Mutate proxied (NOT rawEntry)
proxied.data.val = 2;
await Promise.resolve();
const flat = patches.flat().map((p) => p.path.join("."));
expect(flat.some((p) => p.endsWith("n1.data.val"))).toBe(true);
stop();
});
it("allows custom synthetic id for Set entry", async () => {
const node = { name: "x" };
const state = deepSignal({ s: new Set<any>() });
const patches: Patch[][] = [];
const stop = watchPatches(state, (p) => patches.push(p));
// Preferred ergonomic helper
addWithId(state.s as any, node, "custom123");
await Promise.resolve();
const flat = patches.flat().map((p) => p.path.join("."));
expect(flat.some((p) => p === "s.custom123")).toBe(true);
stop();
});
describe("Set", () => {
it("emits one structural patch on Set.clear()", async () => {
const st = deepSignal({ s: new Set<any>() });
addWithId(st.s as any, { id: "a", x: 1 }, "a");
addWithId(st.s as any, { id: "b", x: 2 }, "b");
const batches: Patch[][] = [];
const stop = watchPatches(st, (p) => batches.push(p));
st.s.clear();
await Promise.resolve();
const all = batches.flat().map((p) => p.path.join("."));
expect(all).toContain("s");
stop();
});
it("emits delete patch for object entry", async () => {
const st = deepSignal({ s: new Set<any>() });
const obj = { id: "n1", x: 1 };
const patches: Patch[][] = [];
const stop = watchPatches(st, (p) => patches.push(p));
st.s.add(obj);
st.s.delete(obj);
await Promise.resolve();
const all = patches
.flat()
.filter((p) => p.type === "delete")
.map((p) => p.path.join("."));
expect(all).toContain("s.n1");
stop();
});
it("does not emit patch for duplicate add", async () => {
const st = deepSignal({ s: new Set<number>([1]) });
const patches: Patch[][] = [];
const stop = watchPatches(st, (p) => patches.push(p));
st.s.add(1); // duplicate
await Promise.resolve();
// no new patches (size unchanged)
expect(patches.length).toBe(0);
stop();
});
it("does not emit patch deleting non-existent entry", async () => {
const st = deepSignal({ s: new Set<number>([1]) });
const patches: Patch[][] = [];
const stop = watchPatches(st, (p) => patches.push(p));
st.s.delete(2);
await Promise.resolve();
expect(patches.length).toBe(0);
stop();
});
it("addWithId primitive returns primitive and emits patch with primitive key", async () => {
const st = deepSignal({ s: new Set<any>() });
const patches: Patch[][] = [];
const stop = watchPatches(st, (p) => patches.push(p));
const ret = addWithId(st.s as any, 5, "ignored"); // primitives ignore id
expect(ret).toBe(5);
await Promise.resolve();
const paths = patches.flat().map((p) => p.path.join("."));
expect(paths).toContain("s.5");
stop();
});
it("setSetEntrySyntheticId applies custom id without helper", async () => {
const st = deepSignal({ s: new Set<any>() });
const obj = { name: "x" };
setSetEntrySyntheticId(obj, "customX");
const patches: Patch[][] = [];
const stop = watchPatches(st, (p) => patches.push(p));
st.s.add(obj);
await Promise.resolve();
const paths = patches.flat().map((p) => p.path.join("."));
expect(paths).toContain("s.customX");
stop();
});
it("values/entries/forEach proxy nested mutation", async () => {
const st = deepSignal({ s: new Set<any>() });
const entry = addWithId(st.s as any, { id: "e1", inner: { v: 1 } }, "e1");
const batches: Patch[][] = [];
const stop = watchPatches(st, (p) => batches.push(p));
// values()
for (const e of st.s.values()) {
e.inner.v;
}
entry.inner.v = 2;
await Promise.resolve();
const vPaths = batches.flat().map((p) => p.path.join("."));
expect(vPaths.some((p) => p.endsWith("e1.inner.v"))).toBe(true);
stop();
});
it("raw reference mutation produces no deep patch while proxied does", async () => {
const raw = { id: "id1", data: { x: 1 } };
const st = deepSignal({ s: new Set<any>([raw]) });
const batches: Patch[][] = [];
const stop = watchPatches(st, (p) => batches.push(p));
raw.data.x = 2; // mutate raw (no patch expected for deep)
await Promise.resolve();
const afterRaw = batches.flat().map((p) => p.path.join("."));
expect(afterRaw.some((p) => p.endsWith("id1.data.x"))).toBe(false);
// Now mutate via proxied
let proxied: any;
for (const e of st.s.values()) proxied = e;
proxied.data.x = 3;
await Promise.resolve();
const afterProxied = batches.flat().map((p) => p.path.join("."));
expect(afterProxied.some((p) => p.endsWith("id1.data.x"))).toBe(true);
stop();
});
it("synthetic id collision assigns unique blank node id", async () => {
const st = deepSignal({ s: new Set<any>() });
const a1 = { id: "dup", v: 1 };
const a2 = { id: "dup", v: 2 };
const patches: Patch[][] = [];
const stop = watchPatches(st, (p) => patches.push(p));
st.s.add(a1);
st.s.add(a2);
await Promise.resolve();
const keys = patches
.flat()
.filter((p) => p.type === "set")
.map((p) => p.path.slice(-1)[0]);
// Expect two distinct keys
expect(new Set(keys).size).toBe(2);
stop();
});
});
describe("Arrays & mixed batch", () => {
it("emits patches for splice/unshift/shift in single batch", async () => {
const st = deepSignal({ arr: [1, 2, 3] });
const batches: Patch[][] = [];
const stop = watchPatches(st, (p) => batches.push(p));
st.arr.splice(1, 1, 99, 100); // delete index1, add two
st.arr.unshift(0);
st.arr.shift(); // undo
await Promise.resolve();
const paths = batches.flat().map((p) => p.path.join("."));
expect(paths.some((p) => p.startsWith("arr."))).toBe(true);
stop();
});
it("mixed object/array/Set mutations batch together", async () => {
const st = deepSignal({ o: { a: 1 }, arr: [1], s: new Set<any>() });
const batches: Patch[][] = [];
const stop = watchPatches(st, (p) => batches.push(p));
st.o.a = 2;
st.arr.push(2);
addWithId(st.s as any, { id: "z", v: 1 }, "z");
await Promise.resolve();
expect(batches.length).toBe(1);
const paths = batches[0].map((p) => p.path.join("."));
expect(paths).toContain("o.a");
expect(paths).toContain("arr.1");
expect(paths.some((p) => p.startsWith("s."))).toBe(true);
stop();
});
});
});

@ -1,27 +1,58 @@
import { Computed, Effect, isSignal, Signal } from './core'; import { effect as nativeEffect, isSignal, signal as coreSignal } from "./core";
import { hasChanged, isArray, isFunction, isMap, isObject, isPlainObject, isSet, NOOP } from './utils'; import {
import { isDeepSignal, isShallow } from "./deepSignal" hasChanged,
import { ReactiveFlags } from './contents'; isArray,
isFunction,
isMap,
isObject,
isPlainObject,
isSet,
} from "./utils";
import {
isDeepSignal,
isShallow,
subscribeDeepMutations,
getDeepSignalRootId,
} from "./deepSignal";
import { ReactiveFlags } from "./contents";
export type OnCleanup = (cleanupFn: () => void) => void /** Callback passed to watcher side-effects allowing registration of a cleanup function. */
export type WatchEffect = (onCleanup: OnCleanup) => void export type OnCleanup = (cleanupFn: () => void) => void;
/** Signature for watchEffect style sources receiving an {@link OnCleanup}. */
export type WatchEffect = (onCleanup: OnCleanup) => void;
export type WatchSource<T = any> = Signal<T> | Computed<T> | (() => T) /** Source accepted by {@link watch}: plain value, deepSignal proxy, signal/computed, or getter. */
export type WatchSource<T = any> = any | (() => T);
/** Configuration options controlling {@link watch} behavior. */
export interface WatchOptions<Immediate = boolean> { export interface WatchOptions<Immediate = boolean> {
immediate?: Immediate /** Trigger the callback immediately with the current value (default: false). */
deep?: boolean | number immediate?: Immediate;
once?: boolean /** Deep traversal depth: true/Infinity for full, number for limited depth, 0/false for shallow. */
deep?: boolean | number;
/** Auto-stop the watcher after the first successful callback run. */
once?: boolean;
/** If true (default) and deep watching a deepSignal, use version bumps from patch stream instead of traversing. */
patchOptimized?: boolean;
} }
/** User callback signature for {@link watch}. */
export type WatchCallback<V = any, OV = any> = ( export type WatchCallback<V = any, OV = any> = (
value: V, value: V,
oldValue: OV, oldValue: OV,
onCleanup: OnCleanup, onCleanup: OnCleanup
) => any ) => any;
const INITIAL_WATCHER_VALUE = {} const INITIAL_WATCHER_VALUE = {};
let activeWatcher!: Effect /** Internal effect-like interface used for watcher lifecycle & scheduling. */
export interface WatchEffectInstance {
active: boolean;
dirty: boolean;
scheduler: (immediateFirstRun?: boolean) => void;
run: () => any;
stop: () => void;
}
let activeWatcher!: WatchEffectInstance;
// const resetTrackingStack: (Subscriber | undefined)[] = [] // const resetTrackingStack: (Subscriber | undefined)[] = []
@ -57,113 +88,131 @@ let activeWatcher!: Effect
// } // }
export const remove = <T>(arr: T[], el: T): void => { export const remove = <T>(arr: T[], el: T): void => {
const i = arr.indexOf(el) const i = arr.indexOf(el);
if (i > -1) { if (i > -1) {
arr.splice(i, 1) arr.splice(i, 1);
} }
} };
/**
* watch()
* --------------------------------------------------------------
* Unified watcher for:
* - A single signal/computed (function signal)
* - A deepSignal proxy object (tracks nested mutations via traversal)
* - An array of the above (multi-source)
* - A getter function (with optional cleanup) ala watchEffect (cb omitted)
*
* Implementation notes:
* - We create an internal Effect wrapper which schedules `job` on dependency invalidation.
* - `job` evaluates the getter (lazily when needed) and compares new vs old value(s) unless
* forceTrigger or deep mode bypasses the shallow comparison.
* - Deep mode uses `traverse()` to touch nested properties ensuring dependency collection.
* - For watchEffect (no cb) we directly execute the source in the Effect's getter.
*/
/**
* Observe reactive sources (signal/computed/deepSignal/getter) and invoke a callback on change.
* Supports: single source, multi-source array, deep traversal, patch-optimized deepSignal watching,
* value-style (with callback) and effect-style (no callback) usage.
*/
export function watch( export function watch(
source: WatchSource | WatchSource[] | WatchEffect | object, source: WatchSource | WatchSource[] | WatchEffect | object,
cb?: WatchCallback, cb?: WatchCallback,
options: WatchOptions = {} options: WatchOptions = {}
) { ) {
const { once, immediate, deep } = options const { once, immediate, deep, patchOptimized = true } = options;
let effect!: Effect let effectInstance!: WatchEffectInstance;
let getter!: () => any let getter!: () => any;
let forceTrigger = false let forceTrigger = false;
let isMultiSource = false let isMultiSource = false;
// let cleanup = NOOP // let cleanup = NOOP
const signalGetter = (source: object) => { const signalGetter = (source: object) => {
// traverse will happen in wrapped getter below // traverse will happen in wrapped getter below
if (deep) return source if (deep) return source;
// for `deep: false | 0` or shallow reactive, only traverse root-level properties // for `deep: false | 0` or shallow reactive, only traverse root-level properties
if (isShallow(source) || deep === false || deep === 0) if (isShallow(source) || deep === false || deep === 0)
return traverse(source, 1) return traverse(source, 1);
// for `deep: undefined` on a reactive object, deeply traverse all properties // for `deep: undefined` on a reactive object, deeply traverse all properties
return traverse(source) return traverse(source);
} };
let unsubscribePatches: (() => void) | undefined;
let skipTraverse = false; // set when patchOptimized deep watch path chosen
const watchHandle = () => { const watchHandle = () => {
effect.stop() if (unsubscribePatches) unsubscribePatches();
return effect if (effectInstance) effectInstance.stop();
} return effectInstance;
};
if (once && cb) { // once wrapping deferred until after effectInstance created
const _cb = cb
cb = (...args) => {
_cb(...args)
watchHandle()
}
}
if (isSignal(source)) { if (isSignal(source)) {
getter = () => source.value getter = () => (source as any)();
forceTrigger = isShallow(source) forceTrigger = isShallow(source);
} else if (isDeepSignal(source)) { } else if (isDeepSignal(source)) {
getter = () => signalGetter(source) if (deep && patchOptimized) {
forceTrigger = true // Use a version signal updated per relevant patch batch.
const version = coreSignal(0);
const rootId = getDeepSignalRootId(source as any);
unsubscribePatches = subscribeDeepMutations((patches) => {
if (!effectInstance || !effectInstance.active) return;
if (patches.some((p) => p.root === rootId)) {
version(version() + 1);
}
});
getter = () => {
version();
return source;
};
forceTrigger = true;
skipTraverse = true; // we rely on patch version invalidations; no deep traversal needed
} else {
getter = () => signalGetter(source as any);
forceTrigger = true;
}
} else if (isArray(source)) { } else if (isArray(source)) {
isMultiSource = true isMultiSource = true;
forceTrigger = source.some(s => isDeepSignal(s) || isShallow(s)) forceTrigger = source.some((s) => isDeepSignal(s) || isShallow(s));
getter = () => getter = () =>
source.map(s => { source.map((s) => {
if (isSignal(s)) { if (isSignal(s)) return (s as any)();
return s.value else if (isDeepSignal(s)) return signalGetter(s);
} else if (isDeepSignal(s)) { });
return signalGetter(s)
}
})
} else if (isFunction(source)) { } else if (isFunction(source)) {
if (cb) { if (cb) {
// getter with cb // getter with cb
getter = (source as () => any) getter = source as () => any;
} else { } else {
// no cb -> simple effect // no cb -> simple effect (watchEffect)
getter = () => { getter = () =>
// if (cleanup) { (source as any)((_?: any) => {
// pauseTracking() /* ignore cleanup */
// try { });
// cleanup()
// } finally {
// resetTracking()
// }
// }
const currentEffect = activeWatcher
activeWatcher = effect
try {
return source(effect.stop)
} finally {
activeWatcher = currentEffect
}
}
} }
} else { } else {
getter = NOOP getter = () => source;
if (process.env.NODE_ENV !== 'production') {
console.warn(
'Invalid watch source. Source must be a signal, a computed value !',
)
}
} }
if (cb && deep) { if (cb && deep && !skipTraverse) {
const baseGetter = getter const baseGetter = getter;
const depth = deep === true ? Infinity : deep const depth = deep === true ? Infinity : deep;
getter = () => traverse(baseGetter(), depth) getter = () => traverse(baseGetter(), depth);
} }
let oldValue: any = isMultiSource let oldValue: any = isMultiSource
? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE) ? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE)
: INITIAL_WATCHER_VALUE : INITIAL_WATCHER_VALUE;
const job = (immediateFirstRun?: boolean) => { const job = (immediateFirstRun?: boolean) => {
if (!effect.active || (!immediateFirstRun && !effect.dirty)) { if (
return !effectInstance.active ||
(!immediateFirstRun && !effectInstance.dirty)
) {
return;
} }
if (cb) { if (cb) {
// watch(source, cb) // watch(source, cb)
const newValue = effect.run() const newValue = effectInstance.run();
if ( if (
deep || deep ||
@ -172,8 +221,8 @@ export function watch(
? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i])) ? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i]))
: hasChanged(newValue, oldValue)) : hasChanged(newValue, oldValue))
) { ) {
const currentWatcher = activeWatcher const currentWatcher = activeWatcher;
activeWatcher = effect activeWatcher = effectInstance;
try { try {
const args = [ const args = [
newValue, newValue,
@ -181,72 +230,149 @@ export function watch(
oldValue === INITIAL_WATCHER_VALUE oldValue === INITIAL_WATCHER_VALUE
? undefined ? undefined
: isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE : isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE
? [] ? []
: oldValue, : oldValue,
effect.stop, effectInstance.stop,
] ];
// @ts-ignore // @ts-ignore
cb!(...args) cb!(...args);
oldValue = newValue oldValue = newValue;
if (once) watchHandle();
} finally { } finally {
activeWatcher = currentWatcher activeWatcher = currentWatcher;
} }
} }
} else { } else {
// watchEffect // watchEffect
effect.run() effectInstance.run();
if (once) watchHandle();
} }
} };
effect = new Effect(getter) // Create native effect and wrap into Effect-like instance
effect.scheduler = job let stopNative: (() => void) | undefined;
const instance: WatchEffectInstance = {
active: true,
dirty: true,
scheduler: job,
run: () => {
instance.dirty = false;
return getter();
},
stop: () => {
if (instance.active) {
stopNative && stopNative();
instance.active = false;
}
},
};
effectInstance = instance;
// nativeEffect returns a disposer (stop)
stopNative = nativeEffect(() => {
instance.dirty = true;
getter();
instance.scheduler();
});
if (cb) { if (cb) {
if (immediate) { if (immediate) {
job(true) job(true);
} else { } else {
oldValue = effect.run() oldValue = effectInstance.run();
} }
} else { } else {
effect.run() effectInstance.run();
} }
return watchHandle return watchHandle;
}
// -----------------------------
// Patch & unified observe APIs
// -----------------------------
/** Shape of a mutation patch delivered by {@link watchPatches} / {@link observe} in patch mode. */
export interface Patch {
root: symbol;
type: "set" | "delete";
path: (string | number)[];
value?: any;
}
/**
* Filtered subscription to deep mutation patches for a specific deepSignal root.
* @throws if the provided value is not a deepSignal root instance.
*/
export function watchPatches(deepSignal: any, cb: (patches: Patch[]) => void) {
if (!isDeepSignal(deepSignal))
throw new Error("watchPatches() expects a deepSignal root");
const root = getDeepSignalRootId(deepSignal);
return subscribeDeepMutations((batch) => {
const filtered = batch.filter((p) => p.root === root);
if (filtered.length) cb(filtered);
});
}
interface ObserveOptionsValue extends WatchOptions {
mode?: "value";
}
interface ObserveOptionsPatch {
mode: "patch";
}
/** Options accepted by {@link observe} to select value vs patch emission mode. */
type ObserveOptions = ObserveOptionsValue | ObserveOptionsPatch;
// observe(): unifies value watching and patch watching
/** Unified API bridging {@link watch} (value mode) and {@link watchPatches} (patch mode). */
export function observe(source: any, cb: any, options: ObserveOptions = {}) {
if (options.mode === "patch") return watchPatches(source, cb);
return watch(source, cb, options as WatchOptions);
}
// Instrumentation counter for performance tests (number of traverse invocations)
/** Instrumentation counter tracking total `traverse()` invocations (used in tests). */
export let __traverseCount = 0;
/** Reset the traversal instrumentation counter back to 0. */
export function __resetTraverseCount() {
__traverseCount = 0;
} }
/**
* Recursively touch (read) nested properties/entries/values of a reactive structure for dependency collection.
* Depth-limited; protects against cycles via `seen` set; respects ReactiveFlags.SKIP opt-out.
*/
export function traverse( export function traverse(
value: unknown, value: unknown,
depth: number = Infinity, depth: number = Infinity,
seen?: Set<unknown>, seen?: Set<unknown>
): unknown { ): unknown {
__traverseCount++;
if (depth <= 0 || !isObject(value) || (value as any)[ReactiveFlags.SKIP]) { if (depth <= 0 || !isObject(value) || (value as any)[ReactiveFlags.SKIP]) {
return value return value;
} }
seen = seen || new Set() seen = seen || new Set();
if (seen.has(value)) { if (seen.has(value)) {
return value return value;
} }
seen.add(value) seen.add(value);
depth-- depth--;
if (isSignal(value)) { if (isSignal(value)) {
traverse(value.value, depth, seen) traverse((value as any)(), depth, seen);
} else if (isArray(value)) { } else if (isArray(value)) {
for (let i = 0; i < value.length; i++) { for (let i = 0; i < value.length; i++) {
traverse(value[i], depth, seen) traverse(value[i], depth, seen);
} }
} else if (isSet(value) || isMap(value)) { } else if (isSet(value) || isMap(value)) {
value.forEach((v: any) => { value.forEach((v: any) => {
traverse(v, depth, seen) traverse(v, depth, seen);
}) });
} else if (isPlainObject(value)) { } else if (isPlainObject(value)) {
for (const key in value) { for (const key in value) {
traverse(value[key], depth, seen) traverse(value[key], depth, seen);
} }
for (const key of Object.getOwnPropertySymbols(value)) { for (const key of Object.getOwnPropertySymbols(value)) {
if (Object.prototype.propertyIsEnumerable.call(value, key)) { if (Object.prototype.propertyIsEnumerable.call(value, key)) {
traverse(value[key as any], depth, seen) traverse(value[key as any], depth, seen);
} }
} }
} }
return value return value;
} }

@ -1,5 +1,12 @@
import { watch } from "./watch" import { watch } from "./watch";
export function watchEffect(effect: () => void) { /**
return watch(effect, undefined) * Run a reactive effect function immediately and again whenever its accessed dependencies change.
* Provides an optional `onCleanup` registration parameter for teardown logic between re-runs.
* Equivalent to `watch(effectFn)` with no explicit callback.
*/
export function watchEffect(
effect: (onCleanup?: (fn: () => void) => void) => void
) {
return watch(effect as any, undefined);
} }

Loading…
Cancel
Save