Compare commits
No commits in common. '400ba719b50530e3fc08a7867568c97d4d63fa47' and '7dbc947abd7596a10bc7625a0443417acefe6403' have entirely different histories.
400ba719b5
...
7dbc947abd
@ -1,24 +1,42 @@ |
|||||||
# Logs |
# dependencies (bun install) |
||||||
logs |
|
||||||
*.log |
|
||||||
npm-debug.log* |
|
||||||
yarn-debug.log* |
|
||||||
yarn-error.log* |
|
||||||
pnpm-debug.log* |
|
||||||
lerna-debug.log* |
|
||||||
|
|
||||||
node_modules |
node_modules |
||||||
|
|
||||||
|
# output |
||||||
|
out |
||||||
dist |
dist |
||||||
dist-ssr |
*.tgz |
||||||
*.local |
|
||||||
|
# code coverage |
||||||
|
coverage |
||||||
|
*.lcov |
||||||
|
|
||||||
|
# logs |
||||||
|
logs |
||||||
|
_.log |
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json |
||||||
|
|
||||||
|
# dotenv environment variable files |
||||||
|
.env |
||||||
|
.env.development.local |
||||||
|
.env.test.local |
||||||
|
.env.production.local |
||||||
|
.env.local |
||||||
|
|
||||||
|
# caches |
||||||
|
.eslintcache |
||||||
|
.cache |
||||||
|
*.tsbuildinfo |
||||||
|
|
||||||
# Editor directories and files |
# IntelliJ based IDEs |
||||||
.vscode/* |
|
||||||
!.vscode/extensions.json |
|
||||||
.idea |
.idea |
||||||
|
|
||||||
|
# Finder (MacOS) folder config |
||||||
.DS_Store |
.DS_Store |
||||||
*.suo |
|
||||||
*.sln |
|
||||||
*.sw? |
|
||||||
|
|
||||||
coverage |
.astro |
||||||
|
|
||||||
|
# Playwright |
||||||
|
/test-results/ |
||||||
|
/playwright-report/ |
||||||
|
/blob-report/ |
||||||
|
/playwright/.cache/ |
||||||
|
@ -1,168 +1,4 @@ |
|||||||
# alien-deepsignals |
# Multi-Framework Signal Proxies |
||||||
|
|
||||||
Deep structural reactivity for plain objects / arrays / Sets built on top of `alien-signals`. |
|
||||||
|
|
||||||
Core idea: wrap a data tree in a `Proxy` that lazily creates per-property signals the first time you read them. Accessing a property returns the plain value; accessing `$prop` returns the underlying signal function. Deep mutations emit compact batched patch objects you can observe with `watch()`. |
|
||||||
|
|
||||||
## Features |
|
||||||
|
|
||||||
* Lazy: signals & child proxies created only when touched. |
|
||||||
* Deep: nested objects, arrays, Sets proxied on demand. |
|
||||||
* [ ] TODO: Methods might not be proxied (e.g. array.push)? |
|
||||||
* Per-property signals: fine‑grained invalidation without traversal on each change. |
|
||||||
* Patch stream: microtask‑batched granular mutations (paths + op) for syncing external stores / framework adapters. |
|
||||||
* Getter => computed: property getters become derived (readonly) signals automatically. |
|
||||||
* `$` accessors: TypeScript exposes `$prop` for each non‑function key plus `$` / `$length` for arrays. |
|
||||||
* Sets: structural `add/delete/clear` emit patches; object entries get synthetic stable ids (prefers `id` / `@id` fields or auto‑generated blank IDs). |
|
||||||
* Shallow escape hatch: wrap sub-objects with `shallow(obj)` to track only reference replacement. |
|
||||||
|
|
||||||
## Install |
|
||||||
|
|
||||||
```bash |
|
||||||
pnpm add alien-deepsignals |
|
||||||
# or |
|
||||||
npm i alien-deepsignals |
|
||||||
``` |
|
||||||
|
|
||||||
## Quick start |
|
||||||
|
|
||||||
```ts |
|
||||||
import { deepSignal } from 'alien-deepsignals' |
|
||||||
|
|
||||||
const state = deepSignal({ |
|
||||||
count: 0, |
|
||||||
user: { name: 'Ada' }, |
|
||||||
items: [{ id: 'i1', qty: 1 }], |
|
||||||
settings: new Set(['dark']) |
|
||||||
}) |
|
||||||
|
|
||||||
state.count++ // mutate normally |
|
||||||
state.user.name = 'Grace' // nested write |
|
||||||
state.items.push({ id: 'i2', qty: 2 }) |
|
||||||
state.settings.add('beta') |
|
||||||
|
|
||||||
// Direct signal access |
|
||||||
state.$count!.set(5) // update via signal |
|
||||||
console.log(state.$count!()) // read via signal function |
|
||||||
``` |
|
||||||
|
|
||||||
## Watching patches |
|
||||||
|
|
||||||
`watch(root, cb, options?)` observes a deepSignal root and invokes your callback with microtask‑batched mutation patches plus snapshots. |
|
||||||
|
|
||||||
```ts |
|
||||||
import { watch } from 'alien-deepsignals' |
|
||||||
|
|
||||||
const stop = watch(state, ({ patches, oldValue, newValue }) => { |
|
||||||
for (const p of patches) { |
|
||||||
console.log(p.op, p.path.join('.'), 'value' in p ? p.value : p.type) |
|
||||||
} |
|
||||||
}) |
|
||||||
|
|
||||||
state.user.name = 'Lin' |
|
||||||
state.items[0].qty = 3 |
|
||||||
await Promise.resolve() // flush microtask |
|
||||||
stop() |
|
||||||
``` |
|
||||||
|
|
||||||
### Callback event shape |
|
||||||
|
|
||||||
```ts |
|
||||||
type WatchPatchEvent<T> = { |
|
||||||
patches: DeepPatch[] // empty only on immediate |
|
||||||
oldValue: T | undefined // deep-cloned snapshot before batch |
|
||||||
newValue: T // live proxy (already mutated) |
|
||||||
registerCleanup(fn): void // register disposer for next batch/stop |
|
||||||
stopListening(): void // unsubscribe |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
### Options |
|
||||||
|
|
||||||
| Option | Type | Default | Description | |
|
||||||
|--------|------|---------|-------------| |
|
||||||
| `immediate` | boolean | false | Fire once right away with `patches: []`. | |
|
||||||
| `once` | boolean | false | Auto stop after first callback (immediate counts). | |
|
||||||
|
|
||||||
`observe()` is an alias of `watch()`. |
|
||||||
|
|
||||||
## DeepPatch format |
|
||||||
|
|
||||||
```ts |
|
||||||
type DeepPatch = { |
|
||||||
root: symbol // stable id per deepSignal root |
|
||||||
path: (string | number)[] // root-relative segments |
|
||||||
} & ( |
|
||||||
| { op: 'add'; type: 'object' } // assigned object/array/Set entry object |
|
||||||
| { op: 'add'; value: string | number | boolean } // primitive write |
|
||||||
| { op: 'remove' } // deletion |
|
||||||
| { op: 'add'; type: 'set'; value: [] } // Set.clear() |
|
||||||
| { op: 'add'; type: 'set'; value: (string|number|boolean)[] | { [id: string]: object } } // (reserved) |
|
||||||
) |
|
||||||
``` |
|
||||||
|
|
||||||
Notes: |
|
||||||
* `type:'object'` omits value to avoid deep cloning; read from `newValue` if needed. |
|
||||||
* `Set.add(entry)` emits object vs primitive form depending on entry type; path ends with synthetic id. |
|
||||||
* `Set.clear()` emits one structural patch and suppresses per‑entry removals in same batch. |
|
||||||
|
|
||||||
## Sets & synthetic ids |
|
||||||
|
|
||||||
Object entries inside Sets need a stable key. Priority: |
|
||||||
1. `entry.id` |
|
||||||
2. `entry['@id']` |
|
||||||
3. Custom via `setSetEntrySyntheticId(entry, 'myId')` before `add` |
|
||||||
4. Auto `_bN` blank id |
|
||||||
|
|
||||||
Helpers: |
|
||||||
```ts |
|
||||||
import { addWithId, setSetEntrySyntheticId } from 'alien-deepsignals' |
|
||||||
|
|
||||||
setSetEntrySyntheticId(obj, 'custom') |
|
||||||
state.settings.add(obj) |
|
||||||
addWithId(state.settings as any, { x:1 }, 'x1') |
|
||||||
``` |
|
||||||
|
|
||||||
## Shallow |
|
||||||
|
|
||||||
Skip deep proxying of a subtree (only reference replacement tracked): |
|
||||||
```ts |
|
||||||
import { shallow } from 'alien-deepsignals' |
|
||||||
state.config = shallow({ huge: { blob: true } }) |
|
||||||
``` |
|
||||||
|
|
||||||
## TypeScript ergonomics |
|
||||||
|
|
||||||
`DeepSignal<T>` exposes both plain properties and optional `$prop` signal accessors (excluded for function members). Arrays add `$` (index signal map) and `$length`. |
|
||||||
|
|
||||||
```ts |
|
||||||
const state = deepSignal({ count: 0, user: { name: 'A' } }) |
|
||||||
state.count++ // ok |
|
||||||
state.$count!.set(9) // write via signal |
|
||||||
const n: number = state.$count!() // typed number |
|
||||||
``` |
|
||||||
|
|
||||||
## API surface |
|
||||||
|
|
||||||
| Function | Description | |
|
||||||
|----------|-------------| |
|
||||||
| `deepSignal(obj)` | Create (or reuse) reactive deep proxy. | |
|
||||||
| `watch(root, cb, opts?)` | Observe batched deep mutations. | |
|
||||||
| `observe(root, cb, opts?)` | Alias of `watch`. | |
|
||||||
| `peek(obj,key)` | Untracked property read. | |
|
||||||
| `shallow(obj)` | Mark object to skip deep proxying. | |
|
||||||
| `isDeepSignal(val)` | Runtime predicate. | |
|
||||||
| `isShallow(val)` | Was value marked shallow. | |
|
||||||
| `setSetEntrySyntheticId(obj,id)` | Assign custom Set entry id. | |
|
||||||
| `addWithId(set, entry, id)` | Insert with desired synthetic id. | |
|
||||||
| `subscribeDeepMutations(root, cb)` | Low-level patch stream (used by watch). | |
|
||||||
|
|
||||||
## Credits |
|
||||||
|
|
||||||
Inspired by [deepsignal](https://github.com/luisherranz/deepsignal) – thanks to @luisherranz. Re-imagined with patch batching & Set support. |
|
||||||
|
|
||||||
## License |
|
||||||
|
|
||||||
MIT |
|
||||||
|
|
||||||
|
|
||||||
|
Thanks for providing the basic multi-framework template: |
||||||
|
https://github.com/aleksadencic/multi-framework-app |
@ -0,0 +1,11 @@ |
|||||||
|
import { defineConfig } from "astro/config"; |
||||||
|
|
||||||
|
import react from "@astrojs/react"; |
||||||
|
import vue from "@astrojs/vue"; |
||||||
|
import svelte from "@astrojs/svelte"; |
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({ |
||||||
|
integrations: [react(), vue(), svelte()], |
||||||
|
srcDir: "./src/app", |
||||||
|
}); |
File diff suppressed because it is too large
Load Diff
@ -1,53 +1,43 @@ |
|||||||
{ |
{ |
||||||
"name": "alien-deepsignals", |
"name": "multi-framework-signal-proxies", |
||||||
"version": "0.1.0", |
"version": "0.1.0", |
||||||
"private": false, |
"private": true, |
||||||
"author": "CCherry07", |
"type": "module", |
||||||
"license": "MIT", |
|
||||||
"main": "dist/index.js", |
|
||||||
"module": "dist/index.mjs", |
|
||||||
"types": "dist/index.d.ts", |
|
||||||
"files": [ |
|
||||||
"dist" |
|
||||||
], |
|
||||||
"exports": { |
|
||||||
".": { |
|
||||||
"import": "./dist/index.mjs", |
|
||||||
"require": "./dist/index.js" |
|
||||||
} |
|
||||||
}, |
|
||||||
"description": "AlienDeepSignals 🧶 -alien signals, but using regular JavaScript objects", |
|
||||||
"scripts": { |
"scripts": { |
||||||
"test": "vitest --coverage", |
"dev": "astro dev", |
||||||
"dev": "tsup --watch src", |
"start": "astro dev", |
||||||
"build": "tsup", |
"build": "astro build", |
||||||
"release": "bumpp && npm run build && npm publish --registry=https://registry.npmjs.org/" |
"preview": "astro preview", |
||||||
|
"astro": "astro", |
||||||
|
"test": "vitest", |
||||||
|
"test:e2e": "playwright test" |
||||||
}, |
}, |
||||||
"dependencies": { |
"dependencies": { |
||||||
"alien-signals": "^2.0.7" |
"@astrojs/react": "4.3.0", |
||||||
|
"@astrojs/svelte": "7.1.0", |
||||||
|
"@astrojs/vue": "^5.1.0", |
||||||
|
"@gn8/alien-signals-react": "^0.1.1", |
||||||
|
"@gn8/alien-signals-solid": "^0.1.1", |
||||||
|
"@gn8/alien-signals-svelte": "^0.1.1", |
||||||
|
"@gn8/alien-signals-vue": "^0.1.1", |
||||||
|
"@types/react": "19.1.10", |
||||||
|
"@types/react-dom": "19.1.7", |
||||||
|
"alien-deepsignals": "^0.1.0", |
||||||
|
"alien-signals": "^2.0.7", |
||||||
|
"astro": "5.13.2", |
||||||
|
"install": "^0.13.0", |
||||||
|
"npm": "^11.5.2", |
||||||
|
"react": "19.1.1", |
||||||
|
"react-dom": "19.1.1", |
||||||
|
"svelte": "5.38.2", |
||||||
|
"vue": "3.5.19" |
||||||
}, |
}, |
||||||
"devDependencies": { |
"devDependencies": { |
||||||
"@types/node": "^22.10.9", |
"@playwright/test": "^1.55.0", |
||||||
"@vitest/coverage-v8": "3.0.2", |
"@types/node": "24.3.0", |
||||||
"bumpp": "^9.9.2", |
"@types/react": "19.1.10", |
||||||
"tsup": "^8.3.5", |
"@types/react-dom": "19.1.7", |
||||||
"typescript": "^5.4.3", |
"vite": "7.1.3", |
||||||
"vitest": "^3.0.2" |
"vitest": "^3.2.4" |
||||||
}, |
} |
||||||
"repository": { |
|
||||||
"type": "git", |
|
||||||
"url": "git+https://github.com/CCherry07/alien-deepsignals.git" |
|
||||||
}, |
|
||||||
"keywords": [ |
|
||||||
"signal", |
|
||||||
"signals", |
|
||||||
"deepsignals", |
|
||||||
"alien-signals", |
|
||||||
"alien-deepsignals" |
|
||||||
], |
|
||||||
"bugs": { |
|
||||||
"url": "https://github.com/CCherry07/alien-deepsignals/issues" |
|
||||||
}, |
|
||||||
"homepage": "https://github.com/CCherry07/alien-deepsignals#readme", |
|
||||||
"packageManager": "pnpm@9.14.2+sha512.6e2baf77d06b9362294152c851c4f278ede37ab1eba3a55fda317a4a17b209f4dbb973fb250a77abc463a341fcb1f17f17cfa24091c4eb319cda0d9b84278387" |
|
||||||
} |
} |
||||||
|
@ -0,0 +1,79 @@ |
|||||||
|
import { defineConfig, devices } from "@playwright/test"; |
||||||
|
|
||||||
|
/** |
||||||
|
* Read environment variables from file. |
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/ |
||||||
|
// import dotenv from 'dotenv';
|
||||||
|
// import path from 'path';
|
||||||
|
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||||
|
|
||||||
|
/** |
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/ |
||||||
|
export default defineConfig({ |
||||||
|
testDir: "./src/frontends/tests", |
||||||
|
/* Run tests in files in parallel */ |
||||||
|
fullyParallel: true, |
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */ |
||||||
|
forbidOnly: !!process.env.CI, |
||||||
|
/* Retry on CI only */ |
||||||
|
retries: process.env.CI ? 2 : 0, |
||||||
|
/* Opt out of parallel tests on CI. */ |
||||||
|
workers: process.env.CI ? 1 : undefined, |
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ |
||||||
|
reporter: "html", |
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ |
||||||
|
use: { |
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */ |
||||||
|
baseURL: "http://localhost:4321", |
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ |
||||||
|
trace: "on-first-retry", |
||||||
|
}, |
||||||
|
|
||||||
|
/* Configure projects for major browsers */ |
||||||
|
projects: [ |
||||||
|
{ |
||||||
|
name: "chromium", |
||||||
|
use: { ...devices["Desktop Chrome"] }, |
||||||
|
}, |
||||||
|
|
||||||
|
{ |
||||||
|
name: "firefox", |
||||||
|
use: { ...devices["Desktop Firefox"] }, |
||||||
|
}, |
||||||
|
|
||||||
|
{ |
||||||
|
name: "webkit", |
||||||
|
use: { ...devices["Desktop Safari"] }, |
||||||
|
}, |
||||||
|
|
||||||
|
/* Test against mobile viewports. */ |
||||||
|
// {
|
||||||
|
// name: 'Mobile Chrome',
|
||||||
|
// use: { ...devices['Pixel 5'] },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Safari',
|
||||||
|
// use: { ...devices['iPhone 12'] },
|
||||||
|
// },
|
||||||
|
|
||||||
|
/* Test against branded browsers. */ |
||||||
|
// {
|
||||||
|
// name: 'Microsoft Edge',
|
||||||
|
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Google Chrome',
|
||||||
|
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||||
|
// },
|
||||||
|
], |
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */ |
||||||
|
webServer: { |
||||||
|
command: "npm run dev", |
||||||
|
url: "http://localhost:4321", |
||||||
|
reuseExistingServer: !process.env.CI, |
||||||
|
}, |
||||||
|
}); |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,42 @@ |
|||||||
|
--- |
||||||
|
const { react, svelte, vue, astro } = Astro.props; |
||||||
|
|
||||||
|
const frameworkName = Object.keys(Astro.props)[0]; |
||||||
|
--- |
||||||
|
|
||||||
|
<div class:list={["box", { react, svelte, vue, astro }]}> |
||||||
|
<p class="title">{frameworkName}</p> |
||||||
|
<div class="wrap"> |
||||||
|
<slot /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.box { |
||||||
|
border: 2px solid rgb(88, 88, 81); |
||||||
|
border-radius: 1em; |
||||||
|
|
||||||
|
padding: 1em; |
||||||
|
margin-top: 1em; |
||||||
|
|
||||||
|
margin-left: auto; |
||||||
|
margin-right: auto; |
||||||
|
max-width: 60%; |
||||||
|
} |
||||||
|
.wrap { |
||||||
|
padding: 10px; |
||||||
|
} |
||||||
|
.title { |
||||||
|
background: var(--highlightColor); |
||||||
|
padding: 0 10px; |
||||||
|
} |
||||||
|
.vue { |
||||||
|
background-color: rgb(255, 212, 255); |
||||||
|
} |
||||||
|
.react { |
||||||
|
background-color: rgb(189, 216, 255); |
||||||
|
} |
||||||
|
.svelte { |
||||||
|
background-color: rgb(255, 216, 189); |
||||||
|
} |
||||||
|
</style> |
@ -0,0 +1,7 @@ |
|||||||
|
import { HelloWorldReact } from "src/frontends/react/HelloWorld"; |
||||||
|
|
||||||
|
const Root = () => { |
||||||
|
return <HelloWorldReact />; |
||||||
|
}; |
||||||
|
|
||||||
|
export default Root; |
@ -0,0 +1,5 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import HelloWorld from "src/frontends/svelte/HelloWorld.svelte"; |
||||||
|
</script> |
||||||
|
|
||||||
|
<HelloWorld /> |
@ -0,0 +1,9 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import HelloWorld from 'src/frontends/vue/HelloWorld.vue'; |
||||||
|
|
||||||
|
// |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<HelloWorld></HelloWorld> |
||||||
|
</template> |
@ -0,0 +1,21 @@ |
|||||||
|
--- |
||||||
|
import Highlight from "../components/Highlight.astro"; |
||||||
|
--- |
||||||
|
|
||||||
|
<!doctype html> |
||||||
|
<html lang="en" data-theme="dark"> |
||||||
|
<head> |
||||||
|
<meta name="viewport" content="width=device-width" /> |
||||||
|
<meta name="generator" content={Astro.generator} /> |
||||||
|
<title>Multi-Framework Signal Experiments</title> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<Highlight astro> |
||||||
|
<header class="container">Multi-Framework Signal Experiments</header> |
||||||
|
|
||||||
|
<main class="container"> |
||||||
|
<slot /> |
||||||
|
</main> |
||||||
|
</Highlight> |
||||||
|
</body> |
||||||
|
</html> |
@ -0,0 +1,24 @@ |
|||||||
|
--- |
||||||
|
import Layout from "../layouts/Layout.astro"; |
||||||
|
import Highlight from "../components/Highlight.astro"; |
||||||
|
|
||||||
|
import VueRoot from "../components/VueRoot.vue"; |
||||||
|
import ReactRoot from "../components/ReactRoot"; |
||||||
|
import SvelteRoot from "../components/SvelteRoot.svelte"; |
||||||
|
|
||||||
|
const title = "Multi-framework app"; |
||||||
|
--- |
||||||
|
|
||||||
|
<Layout title={title}> |
||||||
|
<Highlight vue> |
||||||
|
<VueRoot client:only /> |
||||||
|
</Highlight> |
||||||
|
|
||||||
|
<Highlight react> |
||||||
|
<ReactRoot client:only="react" /> |
||||||
|
</Highlight> |
||||||
|
|
||||||
|
<Highlight svelte> |
||||||
|
<SvelteRoot client:only /> |
||||||
|
</Highlight> |
||||||
|
</Layout> |
@ -0,0 +1,3 @@ |
|||||||
|
import { atom } from "nanostores"; |
||||||
|
|
||||||
|
export const selectedFilters = atom<string[]>([]); |
@ -1,5 +0,0 @@ |
|||||||
export enum ReactiveFlags { |
|
||||||
IS_SIGNAL = '__v_isSignal', |
|
||||||
SKIP = "__v_skip", |
|
||||||
IS_SHALLOW = "__v_isShallow", |
|
||||||
} |
|
@ -1,163 +0,0 @@ |
|||||||
/** Lightweight façade adding ergonomic helpers (.value/.peek/.get/.set) to native alien-signals function signals. */ |
|
||||||
// Native re-exports for advanced usage.
|
|
||||||
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>> & { |
|
||||||
/** Tracking read / write via property syntax */ |
|
||||||
value: T; |
|
||||||
/** Non-tracking read */ |
|
||||||
peek(): T; |
|
||||||
/** Alias for tracking read */ |
|
||||||
get(): T; |
|
||||||
/** Write helper */ |
|
||||||
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; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Decorate a native computed function similarly (readonly value accessor). |
|
||||||
*/ |
|
||||||
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; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Create a new writable function-form signal enhanced with `.value`, `.peek()`, `.get()`, `.set()`. |
|
||||||
* |
|
||||||
* @example |
|
||||||
* const count = signal(0); |
|
||||||
* count(); // 0 (track)
|
|
||||||
* count(1); // write
|
|
||||||
* count.value; // 1 (track)
|
|
||||||
* count.peek(); // 1 (non-tracking)
|
|
||||||
*/ |
|
||||||
export const signal = <T>(v?: T) => tagSignal(alienSignal(v)); |
|
||||||
/** |
|
||||||
* Create a lazy computed (readonly) signal derived from other signals. |
|
||||||
* The returned function is tagged with `.value` and `.peek()` for convenience. |
|
||||||
*/ |
|
||||||
export const computed = <T>(getter: () => T) => |
|
||||||
tagComputed(alienComputed(getter)); |
|
||||||
|
|
||||||
/** Union allowing a plain value or a writable signal wrapping that value. */ |
|
||||||
export type MaybeSignal<T = any> = T | ReturnType<typeof signal>; |
|
||||||
/** Union allowing value, writable signal, computed signal or plain getter function. */ |
|
||||||
export type MaybeSignalOrGetter<T = any> = |
|
||||||
| MaybeSignal<T> |
|
||||||
| ReturnType<typeof computed> |
|
||||||
| (() => T); |
|
||||||
/** 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; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
/** Resolve a plain value, a signal/computed or a getter function to its current value. */ |
|
||||||
// Lightweight direct resolver (inlined former toValue/unSignal logic)
|
|
||||||
/** |
|
||||||
* Resolve a possibly reactive input to its current value. |
|
||||||
* Accepts: plain value, writable signal, computed signal, or getter function. |
|
||||||
* Signals & getters are invoked once; plain values are returned directly. |
|
||||||
*/ |
|
||||||
export function toValue<T>(src: MaybeSignalOrGetter<T>): T { |
|
||||||
return isFunction(src) |
|
||||||
? (src as any)() |
|
||||||
: isSignal(src) |
|
||||||
? (src as any)() |
|
||||||
: (src as any); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* 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 { |
|
||||||
alienStartBatch(); |
|
||||||
try { |
|
||||||
return fn(); |
|
||||||
} finally { |
|
||||||
alienEndBatch(); |
|
||||||
} |
|
||||||
} |
|
@ -1,810 +0,0 @@ |
|||||||
import { ReactiveFlags } from "./contents"; |
|
||||||
import { computed, signal, isSignal } from "./core"; |
|
||||||
/** |
|
||||||
* deepSignal: wrap an object / array / Set graph in lazy per-property signals plus an optional deep patch stream. |
|
||||||
* - `$prop` returns a signal; plain prop returns its current value. |
|
||||||
* - Getter props become computed signals. |
|
||||||
* - Arrays expose `$` (index signals) & `$length`; Sets emit structural entry patches with synthetic ids. |
|
||||||
* - subscribeDeepMutations(root, cb) batches set/delete ops per microtask (DeepPatch[]). |
|
||||||
* - shallow(obj) skips deep proxying of a subtree. |
|
||||||
*/ |
|
||||||
|
|
||||||
/** A batched deep mutation (set/add/remove) from a deepSignal root. */ |
|
||||||
export type DeepPatch = { |
|
||||||
/** Unique identifier for the deep signal root which produced this patch. */ |
|
||||||
root: symbol; |
|
||||||
/** Property path (array indices, object keys, synthetic Set entry ids) from the root to the mutated location. */ |
|
||||||
path: (string | number)[]; |
|
||||||
} & ( |
|
||||||
| DeepSetAddPatch |
|
||||||
| DeepSetRemovePatch |
|
||||||
| DeepObjectAddPatch |
|
||||||
| DeepRemovePatch |
|
||||||
| DeepLiteralAddPatch |
|
||||||
); |
|
||||||
export interface DeepSetAddPatch { |
|
||||||
/** Mutation kind applied at the resolved `path`. */ |
|
||||||
op: "add"; |
|
||||||
type: "set"; |
|
||||||
/** New value for `set` mutations (omitted for `delete`). */ |
|
||||||
value: (number | string | boolean)[] | { [id: string]: object }; |
|
||||||
} |
|
||||||
export interface DeepSetRemovePatch { |
|
||||||
/** Mutation kind applied at the resolved `path`. */ |
|
||||||
op: "remove"; |
|
||||||
type: "set"; |
|
||||||
/** The value to be removed from the set. Either a literal or the key (id) of an object. */ |
|
||||||
value: string | number | boolean; |
|
||||||
} |
|
||||||
export interface DeepObjectAddPatch { |
|
||||||
/** Mutation kind applied at the resolved `path`. */ |
|
||||||
op: "add"; |
|
||||||
type: "object"; |
|
||||||
} |
|
||||||
|
|
||||||
export interface DeepRemovePatch { |
|
||||||
/** Mutation kind applied at the resolved `path`. */ |
|
||||||
op: "remove"; |
|
||||||
} |
|
||||||
export interface DeepLiteralAddPatch { |
|
||||||
/** Mutation kind applied at the resolved `path` */ |
|
||||||
op: "add"; |
|
||||||
/** The literal value to be added at the resolved `path` */ |
|
||||||
value: string | number | boolean; |
|
||||||
} |
|
||||||
|
|
||||||
/** Callback signature for subscribeDeepMutations. */ |
|
||||||
export type DeepPatchSubscriber = (patches: DeepPatch[]) => void; |
|
||||||
|
|
||||||
/** Minimal per-proxy metadata for path reconstruction. */ |
|
||||||
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; |
|
||||||
} |
|
||||||
|
|
||||||
// Proxy -> metadata
|
|
||||||
const proxyMeta = new WeakMap<object, ProxyMeta>(); |
|
||||||
// Root symbol -> subscribers
|
|
||||||
const mutationSubscribers = new Map<symbol, Set<DeepPatchSubscriber>>(); |
|
||||||
// Pending patches grouped per root (flushed once per microtask)
|
|
||||||
let pendingPatches: Map<symbol, DeepPatch[]> | null = null; |
|
||||||
let microtaskScheduled = false; |
|
||||||
|
|
||||||
/** Sentinel symbol; get concrete root id via getDeepSignalRootId(proxy). */ |
|
||||||
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 = new Map(); |
|
||||||
const root = patch.root; |
|
||||||
let list = pendingPatches.get(root); |
|
||||||
if (!list) { |
|
||||||
list = []; |
|
||||||
pendingPatches.set(root, list); |
|
||||||
} |
|
||||||
list.push(patch); |
|
||||||
if (!microtaskScheduled) { |
|
||||||
microtaskScheduled = true; |
|
||||||
queueMicrotask(() => { |
|
||||||
microtaskScheduled = false; |
|
||||||
const groups = pendingPatches; |
|
||||||
pendingPatches = null; |
|
||||||
if (!groups) return; |
|
||||||
for (const [rootId, patches] of groups) { |
|
||||||
if (!patches.length) continue; |
|
||||||
const subs = mutationSubscribers.get(rootId); |
|
||||||
if (subs) subs.forEach((cb) => cb(patches)); |
|
||||||
} |
|
||||||
}); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** Subscribe to microtask-batched deep patches for a root (returns unsubscribe). */ |
|
||||||
export function subscribeDeepMutations( |
|
||||||
root: object | symbol, |
|
||||||
sub: DeepPatchSubscriber |
|
||||||
): () => void { |
|
||||||
const rootId = typeof root === "symbol" ? root : getDeepSignalRootId(root); |
|
||||||
if (!rootId) |
|
||||||
throw new Error( |
|
||||||
"subscribeDeepMutations() expects a deepSignal root proxy or root id symbol" |
|
||||||
); |
|
||||||
let set = mutationSubscribers.get(rootId); |
|
||||||
if (!set) { |
|
||||||
set = new Set(); |
|
||||||
mutationSubscribers.set(rootId, set); |
|
||||||
} |
|
||||||
set.add(sub); |
|
||||||
return () => { |
|
||||||
const bucket = mutationSubscribers.get(rootId); |
|
||||||
if (!bucket) return; |
|
||||||
bucket.delete(sub); |
|
||||||
if (bucket.size === 0) mutationSubscribers.delete(rootId); |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
/** Return the stable root symbol for any deepSignal proxy (undefined if not one). */ |
|
||||||
export function getDeepSignalRootId(obj: any): symbol | undefined { |
|
||||||
return proxyMeta.get(obj)?.root; |
|
||||||
} |
|
||||||
|
|
||||||
// Proxy -> Map of property name -> signal function
|
|
||||||
/** Proxy -> Map<propertyName, signalFn> (lazy). */ |
|
||||||
const proxyToSignals = new WeakMap(); |
|
||||||
// Raw object/array/Set -> stable proxy
|
|
||||||
const objToProxy = new WeakMap(); |
|
||||||
// Raw array -> `$` meta proxy with index signals
|
|
||||||
const arrayToArrayOfSignals = new WeakMap(); |
|
||||||
// Objects already proxied or marked shallow
|
|
||||||
const ignore = new WeakSet(); |
|
||||||
// Object -> signal counter for enumeration invalidation
|
|
||||||
const objToIterable = new WeakMap(); |
|
||||||
const rg = /^\$/; |
|
||||||
const descriptor = Object.getOwnPropertyDescriptor; |
|
||||||
let peeking = false; |
|
||||||
|
|
||||||
// Deep array interface refining callback parameter types.
|
|
||||||
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 ids for Set entry objects (stable key for patches)
|
|
||||||
let __blankNodeCounter = 0; |
|
||||||
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 id before 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; |
|
||||||
}; |
|
||||||
/** Add entry with synthetic id; returns proxied object if applicable. */ |
|
||||||
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; |
|
||||||
} |
|
||||||
|
|
||||||
/** Is value a deepSignal-managed proxy? */ |
|
||||||
export const isDeepSignal = (source: any) => { |
|
||||||
return proxyToSignals.has(source); |
|
||||||
}; |
|
||||||
|
|
||||||
/** Was value explicitly marked shallow? */ |
|
||||||
export const isShallow = (source: any) => { |
|
||||||
return ignore.has(source); |
|
||||||
}; |
|
||||||
|
|
||||||
/** Create (or reuse) a deep reactive proxy for an object / array / Set. */ |
|
||||||
export const deepSignal = <T extends object>(obj: T): DeepSignal<T> => { |
|
||||||
if (!shouldProxy(obj)) throw new Error("This object can't be observed."); |
|
||||||
if (!objToProxy.has(obj)) { |
|
||||||
// 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); |
|
||||||
}; |
|
||||||
|
|
||||||
/** Read property without tracking (untracked read). */ |
|
||||||
export const peek = < |
|
||||||
T extends DeepSignalObject<object>, |
|
||||||
K extends keyof RevertDeepSignalObject<T> |
|
||||||
>( |
|
||||||
obj: T, |
|
||||||
key: K |
|
||||||
): RevertDeepSignal<RevertDeepSignalObject<T>[K]> => { |
|
||||||
peeking = true; |
|
||||||
const value = obj[key]; |
|
||||||
try { |
|
||||||
peeking = false; |
|
||||||
} catch (e) {} |
|
||||||
return value as RevertDeepSignal<RevertDeepSignalObject<T>[K]>; |
|
||||||
}; |
|
||||||
|
|
||||||
const shallowFlag = Symbol(ReactiveFlags.IS_SHALLOW); |
|
||||||
/** Mark object to skip deep proxying (only reference changes tracked). */ |
|
||||||
export function shallow<T extends object>(obj: T): Shallow<T> { |
|
||||||
ignore.add(obj); |
|
||||||
return obj as Shallow<T>; |
|
||||||
} |
|
||||||
|
|
||||||
const createProxy = ( |
|
||||||
target: object, |
|
||||||
handlers: ProxyHandler<object>, |
|
||||||
rootId?: symbol |
|
||||||
) => { |
|
||||||
const proxy = new Proxy(target, handlers); |
|
||||||
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; |
|
||||||
}; |
|
||||||
|
|
||||||
// Set-specific access & structural patch emission.
|
|
||||||
function getFromSet(raw: Set<any>, key: string, receiver: object): any { |
|
||||||
const meta = proxyMeta.get(receiver); |
|
||||||
// Helper to proxy a single entry (object) & assign synthetic id if needed.
|
|
||||||
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; |
|
||||||
} |
|
||||||
if (objToProxy.has(entry)) return objToProxy.get(entry); |
|
||||||
return entry; |
|
||||||
}; |
|
||||||
// Pre-pass to ensure any existing non-proxied object entries are proxied (enables deep patches after iteration)
|
|
||||||
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; |
|
||||||
} |
|
||||||
// Set entry add: emit object vs literal variant.
|
|
||||||
if (entryVal && typeof entryVal === "object") { |
|
||||||
queuePatch({ |
|
||||||
root: metaNow.root, |
|
||||||
path: [...containerPath, synthetic], |
|
||||||
op: "add", |
|
||||||
type: "object", |
|
||||||
}); |
|
||||||
} else { |
|
||||||
queuePatch({ |
|
||||||
root: metaNow.root, |
|
||||||
path: [...containerPath, synthetic], |
|
||||||
op: "add", |
|
||||||
value: entryVal, |
|
||||||
}); |
|
||||||
} |
|
||||||
} else if (key === "delete") { |
|
||||||
const entry = args[0]; |
|
||||||
const synthetic = getSetEntryKey(entry); |
|
||||||
queuePatch({ |
|
||||||
root: metaNow.root, |
|
||||||
path: [...containerPath, synthetic], |
|
||||||
op: "remove", |
|
||||||
}); |
|
||||||
} else if (key === "clear") { |
|
||||||
// Structural clear: remove prior entry-level patches for this Set this tick.
|
|
||||||
if (pendingPatches) { |
|
||||||
const group = pendingPatches.get(metaNow.root); |
|
||||||
if (group && group.length) { |
|
||||||
for (let i = group.length - 1; i >= 0; i--) { |
|
||||||
const p = group[i]; |
|
||||||
if ( |
|
||||||
p.path.length === containerPath.length + 1 && |
|
||||||
containerPath.every((seg, idx) => p.path[idx] === seg) |
|
||||||
) { |
|
||||||
group.splice(i, 1); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
queuePatch({ |
|
||||||
root: metaNow.root, |
|
||||||
path: containerPath, |
|
||||||
op: "add", |
|
||||||
type: "set", |
|
||||||
value: [], |
|
||||||
}); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
return result; |
|
||||||
}; |
|
||||||
} |
|
||||||
const makeIterator = (pair: boolean) => { |
|
||||||
return function thisIter(this: any) { |
|
||||||
const iterable = raw.values(); |
|
||||||
return { |
|
||||||
[Symbol.iterator]() { |
|
||||||
return { |
|
||||||
next() { |
|
||||||
const n = iterable.next(); |
|
||||||
if (n.done) return n; |
|
||||||
const entry = ensureEntryProxy(n.value); |
|
||||||
return { value: pair ? [entry, entry] : entry, done: false }; |
|
||||||
}, |
|
||||||
}; |
|
||||||
}, |
|
||||||
} as Iterable<any>; |
|
||||||
}; |
|
||||||
}; |
|
||||||
if (key === "values" || key === "keys") return makeIterator(false); |
|
||||||
if (key === "entries") return makeIterator(true); |
|
||||||
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 = (raw as any)[key]; |
|
||||||
if (typeof val === "function") return val.bind(raw); |
|
||||||
return val; |
|
||||||
} |
|
||||||
|
|
||||||
const throwOnMutation = () => { |
|
||||||
throw new Error( |
|
||||||
"Don't mutate the signals directly (use the underlying property/value instead)." |
|
||||||
); |
|
||||||
}; |
|
||||||
|
|
||||||
// Does target define a getter for key?
|
|
||||||
function hasGetter(target: any, key: any) { |
|
||||||
return typeof descriptor(target, key)?.get === "function"; |
|
||||||
} |
|
||||||
|
|
||||||
// Lazily allocate / fetch signal map for a proxy receiver.
|
|
||||||
function getSignals(receiver: object) { |
|
||||||
if (!proxyToSignals.has(receiver)) proxyToSignals.set(receiver, new Map()); |
|
||||||
return proxyToSignals.get(receiver)!; |
|
||||||
} |
|
||||||
|
|
||||||
// Wrap & link child object/array/Set if needed.
|
|
||||||
function ensureChildProxy(value: any, parent: object, key: string | number) { |
|
||||||
if (!shouldProxy(value)) return value; |
|
||||||
if (!objToProxy.has(value)) { |
|
||||||
const parentMeta = proxyMeta.get(parent)!; |
|
||||||
const childProxy = createProxy(value, objectHandlers, parentMeta.root); |
|
||||||
const childMeta = proxyMeta.get(childProxy)!; |
|
||||||
childMeta.parent = parent; |
|
||||||
childMeta.key = key as string; |
|
||||||
objToProxy.set(value, childProxy); |
|
||||||
} |
|
||||||
return objToProxy.get(value); |
|
||||||
} |
|
||||||
|
|
||||||
// Normalize raw property key (handles $-prefix & array meta) -> { key, returnSignal }
|
|
||||||
function normalizeKey( |
|
||||||
target: any, |
|
||||||
fullKey: string, |
|
||||||
isArrayMeta: boolean, |
|
||||||
receiver: object |
|
||||||
) { |
|
||||||
let returnSignal = isArrayMeta || fullKey[0] === "$"; |
|
||||||
if (!isArrayMeta && Array.isArray(target) && returnSignal) { |
|
||||||
if (fullKey === "$") { |
|
||||||
// Provide $ meta proxy for array index signals
|
|
||||||
if (!arrayToArrayOfSignals.has(target)) { |
|
||||||
arrayToArrayOfSignals.set( |
|
||||||
target, |
|
||||||
createProxy(target, arrayHandlers, proxyMeta.get(receiver)?.root) |
|
||||||
); |
|
||||||
} |
|
||||||
return { shortCircuit: arrayToArrayOfSignals.get(target) }; |
|
||||||
} |
|
||||||
returnSignal = fullKey === "$length"; |
|
||||||
} |
|
||||||
const key = returnSignal ? fullKey.replace(rg, "") : fullKey; |
|
||||||
return { key, returnSignal } as any; |
|
||||||
} |
|
||||||
|
|
||||||
// Create computed signal for getter property if needed.
|
|
||||||
function ensureComputed( |
|
||||||
signals: Map<any, any>, |
|
||||||
target: any, |
|
||||||
key: any, |
|
||||||
receiver: any |
|
||||||
) { |
|
||||||
if (!signals.has(key) && hasGetter(target, key)) { |
|
||||||
signals.set( |
|
||||||
key, |
|
||||||
computed(() => Reflect.get(target, key, receiver)) |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Unified get trap factory (object / array meta variant)
|
|
||||||
const get = |
|
||||||
(isArrayMeta: boolean) => |
|
||||||
(target: object, fullKey: string, receiver: object): unknown => { |
|
||||||
if (peeking) return Reflect.get(target, fullKey, receiver); |
|
||||||
// Set handling delegated completely.
|
|
||||||
if (target instanceof Set && typeof fullKey === "string") { |
|
||||||
return getFromSet(target as Set<any>, fullKey, receiver); |
|
||||||
} |
|
||||||
const norm = normalizeKey(target, fullKey, isArrayMeta, receiver); |
|
||||||
if ((norm as any).shortCircuit) return (norm as any).shortCircuit; // returned meta proxy
|
|
||||||
const { key, returnSignal } = norm as { |
|
||||||
key: string; |
|
||||||
returnSignal: boolean; |
|
||||||
}; |
|
||||||
// Symbol fast-path
|
|
||||||
if (typeof key === "symbol" && wellKnownSymbols.has(key)) |
|
||||||
return Reflect.get(target, key, receiver); |
|
||||||
const signals = getSignals(receiver); |
|
||||||
ensureComputed(signals, target, key, receiver); |
|
||||||
if (!signals.has(key)) { |
|
||||||
let value = Reflect.get(target, key, receiver); |
|
||||||
if (returnSignal && typeof value === "function") return; // user asked for signal wrapper of function => ignore
|
|
||||||
value = ensureChildProxy(value, receiver, key); |
|
||||||
signals.set(key, signal(value)); |
|
||||||
} |
|
||||||
const sig = signals.get(key); |
|
||||||
return returnSignal ? sig : sig(); |
|
||||||
}; |
|
||||||
|
|
||||||
// Standard object / array handlers
|
|
||||||
const objectHandlers = { |
|
||||||
get: get(false), |
|
||||||
set(target: object, fullKey: string, val: any, receiver: object): boolean { |
|
||||||
// Respect original getter/setter semantics
|
|
||||||
if (typeof descriptor(target, fullKey)?.set === "function") |
|
||||||
return Reflect.set(target, fullKey, val, receiver); |
|
||||||
if (!proxyToSignals.has(receiver)) proxyToSignals.set(receiver, new Map()); |
|
||||||
const signals = proxyToSignals.get(receiver); |
|
||||||
if (fullKey[0] === "$") { |
|
||||||
if (!isSignal(val)) throwOnMutation(); |
|
||||||
const key = fullKey.replace(rg, ""); |
|
||||||
signals.set(key, val); |
|
||||||
return Reflect.set(target, key, val.peek(), receiver); |
|
||||||
} else { |
|
||||||
let internal = val; |
|
||||||
if (shouldProxy(val)) { |
|
||||||
if (!objToProxy.has(val)) { |
|
||||||
// Link newly wrapped child to its parent for path reconstruction.
|
|
||||||
// In some edge cases parent metadata might not yet be initialized (e.g.,
|
|
||||||
// if a proxied structure was reconstructed in a way that bypassed the
|
|
||||||
// original deepSignal root path). Fall back to creating/assigning it.
|
|
||||||
let parentMeta = proxyMeta.get(receiver); |
|
||||||
if (!parentMeta) { |
|
||||||
// Assign a root id (new symbol) so downstream patches remain groupable.
|
|
||||||
const created: ProxyMeta = { |
|
||||||
root: Symbol("deepSignalRootAuto"), |
|
||||||
} as ProxyMeta; |
|
||||||
proxyMeta.set(receiver, created); |
|
||||||
parentMeta = created; |
|
||||||
} |
|
||||||
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); |
|
||||||
} |
|
||||||
const isNew = !(fullKey in target); |
|
||||||
const result = Reflect.set(target, fullKey, val, receiver); |
|
||||||
|
|
||||||
if (!signals.has(fullKey)) { |
|
||||||
// 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 (Array.isArray(target) && signals.has("length")) |
|
||||||
signals.get("length").set(target.length); |
|
||||||
// Emit patch (after mutation) so subscribers get final value snapshot.
|
|
||||||
const meta = proxyMeta.get(receiver); |
|
||||||
if (meta) { |
|
||||||
// Object/Array/Set assignment at property path.
|
|
||||||
if (val && typeof val === "object") { |
|
||||||
queuePatch({ |
|
||||||
root: meta.root, |
|
||||||
path: buildPath(receiver, fullKey), |
|
||||||
op: "add", |
|
||||||
type: "object", |
|
||||||
}); |
|
||||||
} else { |
|
||||||
queuePatch({ |
|
||||||
root: meta.root, |
|
||||||
path: buildPath(receiver, fullKey), |
|
||||||
op: "add", |
|
||||||
value: val, |
|
||||||
}); |
|
||||||
} |
|
||||||
} |
|
||||||
return result; |
|
||||||
} |
|
||||||
}, |
|
||||||
deleteProperty(target: object, key: string): boolean { |
|
||||||
if (key[0] === "$") throwOnMutation(); |
|
||||||
const signals = proxyToSignals.get(objToProxy.get(target)); |
|
||||||
const result = Reflect.deleteProperty(target, key); |
|
||||||
if (signals && signals.has(key)) signals.get(key).value = undefined; |
|
||||||
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, |
|
||||||
path: buildPath(receiverProxy, key), |
|
||||||
op: "remove", |
|
||||||
}); |
|
||||||
} |
|
||||||
return result; |
|
||||||
}, |
|
||||||
ownKeys(target: object): (string | symbol)[] { |
|
||||||
if (!objToIterable.has(target)) objToIterable.set(target, signal(0)); |
|
||||||
(objToIterable as any)._ = objToIterable.get(target).get(); |
|
||||||
return Reflect.ownKeys(target); |
|
||||||
}, |
|
||||||
}; |
|
||||||
|
|
||||||
// Array `$` meta proxy handlers (index signals only)
|
|
||||||
const arrayHandlers = { |
|
||||||
get: get(true), |
|
||||||
set: throwOnMutation, |
|
||||||
deleteProperty: throwOnMutation, |
|
||||||
}; |
|
||||||
|
|
||||||
const wellKnownSymbols = new Set( |
|
||||||
Object.getOwnPropertyNames(Symbol) |
|
||||||
.map((key) => Symbol[key as WellKnownSymbols]) |
|
||||||
.filter((value) => typeof value === "symbol") |
|
||||||
); |
|
||||||
// Supported constructors (Map intentionally excluded for now)
|
|
||||||
const supported = new Set([Object, Array, Set]); |
|
||||||
const shouldProxy = (val: any): boolean => { |
|
||||||
if (typeof val !== "object" || val === null) return false; |
|
||||||
return supported.has(val.constructor) && !ignore.has(val); |
|
||||||
}; |
|
||||||
|
|
||||||
/** TYPES **/ // Structural deep reactive view of an input type.
|
|
||||||
export type DeepSignal<T> = T extends Function |
|
||||||
? T |
|
||||||
: T extends { [shallowFlag]: true } |
|
||||||
? T |
|
||||||
: T extends Array<unknown> |
|
||||||
? DeepSignalArray<T> |
|
||||||
: T extends object |
|
||||||
? DeepSignalObject<T> |
|
||||||
: T; |
|
||||||
|
|
||||||
/** 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 |
|
||||||
? never |
|
||||||
: ReturnType<typeof signal<T[P]>>; |
|
||||||
} & { |
|
||||||
[P in keyof T]: DeepSignal<T[P]>; |
|
||||||
}; |
|
||||||
|
|
||||||
/** Extract element type from an array. */ |
|
||||||
type ArrayType<T> = T extends Array<infer I> ? I : T; |
|
||||||
/** DeepSignal-enhanced array type (numeric indices & `$` meta accessors). */ |
|
||||||
type DeepSignalArray<T> = DeepArray<ArrayType<T>> & { |
|
||||||
[key: number]: DeepSignal<ArrayType<T>>; |
|
||||||
$?: { [key: number]: ReturnType<typeof signal<ArrayType<T>>> }; |
|
||||||
$length?: ReturnType<typeof signal<number>>; |
|
||||||
}; |
|
||||||
|
|
||||||
/** Marker utility type for objects passed through without deep proxying. */ |
|
||||||
export type Shallow<T extends object> = T & { [shallowFlag]: true }; |
|
||||||
|
|
||||||
/** Framework adapter hook returning a DeepSignal proxy. */ |
|
||||||
export declare const useDeepSignal: <T extends object>(obj: T) => DeepSignal<T>; |
|
||||||
// @ts-ignore
|
|
||||||
// Strip `$`-prefixed synthetic signal accessors from key union.
|
|
||||||
type FilterSignals<K> = K extends `$${string}` ? never : K; |
|
||||||
/** Reverse of DeepSignalObject: remove signal accessors to obtain original object shape. */ |
|
||||||
type RevertDeepSignalObject<T> = Pick<T, FilterSignals<keyof T>>; |
|
||||||
/** Reverse of DeepSignalArray: omit meta accessors. */ |
|
||||||
type RevertDeepSignalArray<T> = Omit<T, "$" | "$length">; |
|
||||||
|
|
||||||
/** Inverse mapped type removing deepSignal wrapper affordances. */ |
|
||||||
export type RevertDeepSignal<T> = T extends Array<unknown> |
|
||||||
? RevertDeepSignalArray<T> |
|
||||||
: T extends object |
|
||||||
? RevertDeepSignalObject<T> |
|
||||||
: T; |
|
||||||
|
|
||||||
/** Subset of ECMAScript well-known symbols we explicitly pass through without proxy wrapping. */ |
|
||||||
type WellKnownSymbols = |
|
||||||
| "asyncIterator" |
|
||||||
| "hasInstance" |
|
||||||
| "isConcatSpreadable" |
|
||||||
| "iterator" |
|
||||||
| "match" |
|
||||||
| "matchAll" |
|
||||||
| "replace" |
|
||||||
| "search" |
|
||||||
| "species" |
|
||||||
| "split" |
|
||||||
| "toPrimitive" |
|
||||||
| "toStringTag" |
|
||||||
| "unscopables"; |
|
@ -0,0 +1,26 @@ |
|||||||
|
import React from "react"; |
||||||
|
import useShape from "../../ng-mock/js-land/frontendAdapters/react/useShape"; |
||||||
|
|
||||||
|
export function HelloWorldReact() { |
||||||
|
const state = useShape("Shape1", ""); |
||||||
|
|
||||||
|
window.reactState = state; |
||||||
|
console.log("react render", state); |
||||||
|
|
||||||
|
if (!state) return <>Loading state</>; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div> |
||||||
|
<p>Hello World from React!</p> |
||||||
|
{state.name} lives at {state.address.street} |
||||||
|
<p></p> |
||||||
|
<button |
||||||
|
onClick={() => { |
||||||
|
state.name = `${state.name} ${state.name}`; |
||||||
|
}} |
||||||
|
> |
||||||
|
Double name |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,19 @@ |
|||||||
|
<script> |
||||||
|
import useShape from "../../ng-mock/js-land/frontendAdapters/svelte/useShape"; |
||||||
|
|
||||||
|
const nestedObject = useShape("Shape1", null); |
||||||
|
</script> |
||||||
|
|
||||||
|
<div> |
||||||
|
<p>Hello World from Svelte!</p> |
||||||
|
|
||||||
|
<pre>{JSON.stringify($nestedObject, null, 4)}</pre> |
||||||
|
<button |
||||||
|
on:click={() => { |
||||||
|
window.svelteState = $nestedObject; |
||||||
|
$nestedObject.name = $nestedObject.name.toUpperCase(); |
||||||
|
}} |
||||||
|
> |
||||||
|
upper-case name |
||||||
|
</button> |
||||||
|
</div> |
@ -0,0 +1,10 @@ |
|||||||
|
import { test, expect } from "@playwright/test"; |
||||||
|
|
||||||
|
test("components load", async ({ page }) => { |
||||||
|
await page.goto("/"); |
||||||
|
await page.waitForSelector(".vue astro-island"); |
||||||
|
|
||||||
|
await expect(page.locator(".vue .title")).toHaveText("vue"); |
||||||
|
await expect(page.locator(".react .title")).toHaveText("react"); |
||||||
|
await expect(page.locator(".svelte .title")).toHaveText("svelte"); |
||||||
|
}); |
@ -0,0 +1,29 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import type { WritableComputedRef } from 'vue'; |
||||||
|
import useShape from '../../ng-mock/js-land/frontendAdapters/vue/useShape'; |
||||||
|
|
||||||
|
const shapeObj = useShape("Shape1", "null"); |
||||||
|
|
||||||
|
window.vueState= shapeObj; |
||||||
|
|
||||||
|
const setName = () => { |
||||||
|
shapeObj.value.name = "Bobby" |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<template> |
||||||
|
<div> |
||||||
|
<p>Hello World from Vue!</p> |
||||||
|
<div v-if="shapeObj != null"> |
||||||
|
<p>Type is <em>{{shapeObj.type}}</em> with street <em>{{shapeObj.address.street}}</em></p> |
||||||
|
|
||||||
|
<button @click="setName"> |
||||||
|
Click to switch name |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
<div v-else> |
||||||
|
Loading state |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
@ -1,17 +0,0 @@ |
|||||||
export * from "./core"; |
|
||||||
export * from "./deepSignal"; |
|
||||||
export * from "./watch"; |
|
||||||
export * from "./watchEffect"; |
|
||||||
export { |
|
||||||
isArray, |
|
||||||
isDate, |
|
||||||
isFunction, |
|
||||||
isMap, |
|
||||||
isObject, |
|
||||||
isPlainObject, |
|
||||||
isPromise, |
|
||||||
isRegExp, |
|
||||||
isSet, |
|
||||||
isString, |
|
||||||
isSymbol, |
|
||||||
} from "./utils"; |
|
@ -0,0 +1,9 @@ |
|||||||
|
import type { Diff } from "../types"; |
||||||
|
|
||||||
|
/** Mock function to apply diffs. Just uses a copy of the diff as the new object. */ |
||||||
|
export function applyDiff(currentState: any, diff: Diff) { |
||||||
|
const clone = JSON.parse(JSON.stringify(diff)); |
||||||
|
Object.keys(clone).forEach((k) => { |
||||||
|
currentState[k] = clone[k]; |
||||||
|
}); |
||||||
|
} |
@ -0,0 +1,84 @@ |
|||||||
|
import updateShape from "src/ng-mock/wasm-land/updateShape"; |
||||||
|
import type { Connection, Diff, Scope, Shape } from "../types"; |
||||||
|
import requestShape from "src/ng-mock/wasm-land/requestShape"; |
||||||
|
import { applyDiff } from "./applyDiff"; |
||||||
|
import { batch, deepSignal, watch } from "alien-deepsignals"; |
||||||
|
import { signal as createSignal, computed } from "alien-signals"; |
||||||
|
|
||||||
|
type ReactiveShapeObject = object; |
||||||
|
|
||||||
|
type ShapeObjectSignal = ReturnType< |
||||||
|
typeof createSignal<{ |
||||||
|
content: ReactiveShapeObject | null; |
||||||
|
}> |
||||||
|
>; |
||||||
|
|
||||||
|
const openConnections: Partial<Record<Shape, ShapeObjectSignal>> = {}; |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a signal for a shape object. |
||||||
|
* The function returns a signal of a shape object in the form: |
||||||
|
* `{content: <shapeObject>}` |
||||||
|
**/ |
||||||
|
export function createSignalObjectForShape( |
||||||
|
shape: Shape, |
||||||
|
scope?: Scope, |
||||||
|
poolSignal = true |
||||||
|
) { |
||||||
|
if (poolSignal && openConnections[shape]) return openConnections[shape]; |
||||||
|
|
||||||
|
// DeepSignal has a different API to alien-signals.
|
||||||
|
// Therefore, we need to create a "root signal" wrapper that is
|
||||||
|
// triggered on deepSignal changes.
|
||||||
|
const rootSignal = createSignal<{ |
||||||
|
content: ReactiveShapeObject | null; |
||||||
|
}>({ content: null }); |
||||||
|
|
||||||
|
// State
|
||||||
|
let stopWatcher: any = null; |
||||||
|
let suspendDeepWatcher = false; |
||||||
|
|
||||||
|
const onUpdateFromDb = (diff: Diff, connectionId: Connection["id"]) => { |
||||||
|
const rootSignalValue = rootSignal(); |
||||||
|
console.log("Update received", connectionId, diff); |
||||||
|
// Set new value from applying the diffs to the old value.
|
||||||
|
|
||||||
|
suspendDeepWatcher = true; |
||||||
|
// We need to replace the root signal for now, so this is redundant.
|
||||||
|
batch(() => { |
||||||
|
if (!rootSignalValue) return; // This shouldn't happen but we make the compiler happy.
|
||||||
|
const { content: proxiedShapeObj } = rootSignalValue; |
||||||
|
applyDiff(proxiedShapeObj, diff); |
||||||
|
|
||||||
|
// We put the proxied object into a new object for the root signal to trigger.
|
||||||
|
rootSignal({ content: proxiedShapeObj }); |
||||||
|
}); |
||||||
|
suspendDeepWatcher = false; |
||||||
|
}; |
||||||
|
|
||||||
|
// Do the actual db request.
|
||||||
|
requestShape(shape, scope, onUpdateFromDb).then( |
||||||
|
({ connectionId, shapeObject }) => { |
||||||
|
// Create a deepSignal to put into the vanilla alien-signal.
|
||||||
|
const proxiedShapeObj = deepSignal(shapeObject); |
||||||
|
|
||||||
|
// Notify DB on changes.
|
||||||
|
stopWatcher = watch( |
||||||
|
proxiedShapeObj, |
||||||
|
(newVal, oldVal, onCleanup) => { |
||||||
|
// Don't update when applying changes from db diffs from the db.
|
||||||
|
if (!suspendDeepWatcher) updateShape(connectionId, newVal); |
||||||
|
}, |
||||||
|
{ deep: true } |
||||||
|
); |
||||||
|
|
||||||
|
// Update the root signal.
|
||||||
|
rootSignal({ content: proxiedShapeObj }); |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
if (poolSignal) openConnections[shape] = rootSignal; |
||||||
|
|
||||||
|
// TODO: Dispose deepSignal root signal disposal.
|
||||||
|
return rootSignal; |
||||||
|
} |
@ -0,0 +1,22 @@ |
|||||||
|
import { useSignal } from "@gn8/alien-signals-react"; |
||||||
|
import { useMemo, useRef } from "react"; |
||||||
|
import { createSignalObjectForShape } from "src/ng-mock/js-land/connector/createSignalObjectForShape"; |
||||||
|
import type { Scope, Shape } from "src/ng-mock/js-land/types"; |
||||||
|
|
||||||
|
// TODO: The universe-signal library causes two renders on change instead of one.
|
||||||
|
// Find a fix
|
||||||
|
|
||||||
|
const useShape = (shape: Shape, scope: Scope) => { |
||||||
|
const signalOfShape = useMemo(() => { |
||||||
|
console.log("react memo called..."); |
||||||
|
return createSignalObjectForShape(shape, scope); |
||||||
|
}, [shape, scope]); |
||||||
|
|
||||||
|
const [{ content: shapeObject }, setShapeObject] = useSignal(signalOfShape); |
||||||
|
|
||||||
|
// We don't need the setter.
|
||||||
|
// The object is recursively proxied and changes are recorded there.
|
||||||
|
return shapeObject; |
||||||
|
}; |
||||||
|
|
||||||
|
export default useShape; |
@ -0,0 +1,16 @@ |
|||||||
|
import { derived } from "svelte/store"; |
||||||
|
import { useSignal } from "@gn8/alien-signals-svelte"; |
||||||
|
import { createSignalObjectForShape } from "src/ng-mock/js-land/connector/createSignalObjectForShape"; |
||||||
|
import type { Scope, Shape } from "src/ng-mock/js-land/types"; |
||||||
|
|
||||||
|
const useShape = (shape: Shape, scope: Scope) => { |
||||||
|
const signalOfShape = createSignalObjectForShape(shape, scope); |
||||||
|
|
||||||
|
const writeableStoreForShape = useSignal(signalOfShape); |
||||||
|
|
||||||
|
// Get the content "deepSignal"
|
||||||
|
const content = derived(writeableStoreForShape, (value) => value.content); |
||||||
|
return content; |
||||||
|
}; |
||||||
|
|
||||||
|
export default useShape; |
@ -0,0 +1,14 @@ |
|||||||
|
import { useSignal } from "@gn8/alien-signals-vue"; |
||||||
|
import { createSignalObjectForShape } from "src/ng-mock/js-land/connector/createSignalObjectForShape"; |
||||||
|
import type { Scope, Shape } from "src/ng-mock/js-land/types"; |
||||||
|
import { computed } from "vue"; |
||||||
|
const useShape = (shape: Shape, scope: Scope) => { |
||||||
|
const signalOfShape = createSignalObjectForShape(shape, scope); |
||||||
|
|
||||||
|
const refOfShape = useSignal(signalOfShape); |
||||||
|
|
||||||
|
// TODO: Maybe `refOfShape.value.content` works too?
|
||||||
|
return computed(() => refOfShape.value.content); |
||||||
|
}; |
||||||
|
|
||||||
|
export default useShape; |
@ -0,0 +1,14 @@ |
|||||||
|
/** The shape of an object requested. */ |
||||||
|
export type Shape = "Shape1" | "Shape2"; |
||||||
|
|
||||||
|
/** The Scope of a shape request */ |
||||||
|
export type Scope = string | string[]; |
||||||
|
|
||||||
|
/** The diff format used to communicate updates between wasm-land and js-land. */ |
||||||
|
export type Diff = object; |
||||||
|
|
||||||
|
/** A connection established between wasm-land and js-land for subscription of a shape. */ |
||||||
|
export type Connection = { |
||||||
|
id: string; |
||||||
|
onUpdateFromWasm: (diff: Diff) => void; |
||||||
|
}; |
@ -0,0 +1,30 @@ |
|||||||
|
import { expect, test } from "vitest"; |
||||||
|
import { createSignalObjectForShape } from "../js-land/connector/createSignalObjectForShape"; |
||||||
|
|
||||||
|
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); |
||||||
|
|
||||||
|
// TODO: Redo
|
||||||
|
test("shape object notification comes back to others", async () => { |
||||||
|
const object1 = createSignalObjectForShape("Shape1"); |
||||||
|
const object2 = createSignalObjectForShape("Shape1"); |
||||||
|
|
||||||
|
const object3 = createSignalObjectForShape("Shape2"); |
||||||
|
const object4 = createSignalObjectForShape("Shape2"); |
||||||
|
|
||||||
|
wait(50); |
||||||
|
|
||||||
|
// Update object 1 and expect object 2 to update as well.
|
||||||
|
object1()!.name = "Updated name from object1"; |
||||||
|
|
||||||
|
wait(30); |
||||||
|
expect(object2()!.name).toBe("Updated name from object1"); |
||||||
|
|
||||||
|
// Expect object of different shape not to have changed.
|
||||||
|
expect(object3().name).toBe("Niko's cat"); |
||||||
|
|
||||||
|
// Update object 4 and expect object 3 with same shape to have updated.
|
||||||
|
object4()!.name = "Updated name from object4"; |
||||||
|
|
||||||
|
wait(30); |
||||||
|
expect(object3()!.name).toBe("Updated name from object4"); |
||||||
|
}); |
@ -0,0 +1,53 @@ |
|||||||
|
import * as shapeManager from "./shapeManager"; |
||||||
|
import type { WasmConnection, Diff, Scope } from "./types"; |
||||||
|
import type { Shape } from "../js-land/types"; |
||||||
|
|
||||||
|
const mockShapeObject1 = { |
||||||
|
type: "Person", |
||||||
|
name: "Bob", |
||||||
|
address: { |
||||||
|
street: "First street", |
||||||
|
houseNumber: "15", |
||||||
|
}, |
||||||
|
hasChildren: true, |
||||||
|
numberOfHouses: 0, |
||||||
|
}; |
||||||
|
const mockShapeObject2 = { |
||||||
|
type: "Cat", |
||||||
|
name: "Niko's cat", |
||||||
|
age: 12, |
||||||
|
numberOfHomes: 3, |
||||||
|
address: { |
||||||
|
street: "Niko's street", |
||||||
|
houseNumber: "15", |
||||||
|
floor: 0, |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
let connectionIdCounter = 1; |
||||||
|
|
||||||
|
export default async function requestShape( |
||||||
|
shape: Shape, |
||||||
|
scope: Scope | undefined, |
||||||
|
callback: (diff: Diff, connectionId: WasmConnection["id"]) => void |
||||||
|
): Promise<{ |
||||||
|
connectionId: string; |
||||||
|
shapeObject: object; |
||||||
|
}> { |
||||||
|
const connection: WasmConnection = { |
||||||
|
id: String(connectionIdCounter++), |
||||||
|
shape, |
||||||
|
// Create a deep copy to prevent accidental by-reference changes.
|
||||||
|
state: JSON.parse( |
||||||
|
JSON.stringify(shape === "Shape1" ? mockShapeObject1 : mockShapeObject2) |
||||||
|
), |
||||||
|
callback, |
||||||
|
}; |
||||||
|
|
||||||
|
shapeManager.connections.set(connection.id, connection); |
||||||
|
|
||||||
|
return { |
||||||
|
connectionId: connection.id, |
||||||
|
shapeObject: connection.state, |
||||||
|
}; |
||||||
|
} |
@ -0,0 +1,10 @@ |
|||||||
|
import type { Diff, ObjectState, WasmConnection } from "./types"; |
||||||
|
|
||||||
|
const connections: Map<WasmConnection["id"], WasmConnection> = new Map(); |
||||||
|
|
||||||
|
/** Mock function to apply diffs. Just uses a copy of the diff as the new object. */ |
||||||
|
export function applyDiff(currentState: ObjectState, diff: Diff): ObjectState { |
||||||
|
return JSON.parse(JSON.stringify(diff)); |
||||||
|
} |
||||||
|
|
||||||
|
export { connections }; |
@ -0,0 +1,17 @@ |
|||||||
|
import type { Shape } from "../js-land/types"; |
||||||
|
|
||||||
|
/** The Scope of a shape request */ |
||||||
|
export type Scope = string | string[]; |
||||||
|
|
||||||
|
/** The diff format used to communicate updates between wasm-land and js-land. */ |
||||||
|
export type Diff = object; |
||||||
|
|
||||||
|
export type ObjectState = object; |
||||||
|
|
||||||
|
/** A connection established between wasm-land and js-land for subscription of a shape. */ |
||||||
|
export type WasmConnection = { |
||||||
|
id: string; |
||||||
|
shape: Shape; |
||||||
|
state: ObjectState; |
||||||
|
callback: (diff: Diff, connectionId: WasmConnection["id"]) => void; |
||||||
|
}; |
@ -0,0 +1,22 @@ |
|||||||
|
import * as shapeManager from "./shapeManager"; |
||||||
|
import type { WasmConnection, Diff } from "./types"; |
||||||
|
|
||||||
|
export default async function updateShape( |
||||||
|
connectionId: WasmConnection["id"], |
||||||
|
diff: Diff |
||||||
|
) { |
||||||
|
const connection = shapeManager.connections.get(connectionId); |
||||||
|
if (!connection) throw new Error("No Connection found."); |
||||||
|
|
||||||
|
console.log("BACKEND: Received update request from ", connectionId); |
||||||
|
|
||||||
|
const newState = shapeManager.applyDiff(connection.state, diff); |
||||||
|
connection.state = newState; |
||||||
|
|
||||||
|
shapeManager.connections.forEach((con) => { |
||||||
|
if (con.shape == connection.shape) { |
||||||
|
con.state = newState; |
||||||
|
con.callback(diff, con.id); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
@ -1,81 +0,0 @@ |
|||||||
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
@ -1,47 +0,0 @@ |
|||||||
import { describe, it, expect, beforeEach } from "vitest"; |
|
||||||
import { deepSignal } from "../deepSignal"; |
|
||||||
import { watch } 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 patch-only simplified performance placeholder", () => { |
|
||||||
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("receives a single batch of patches after deep mutations", async () => { |
|
||||||
let batches = 0; |
|
||||||
const { stopListening: stop } = watch(store, ({ patches }) => { |
|
||||||
if (patches.length) batches++; |
|
||||||
}); |
|
||||||
mutateAll(); |
|
||||||
await Promise.resolve(); |
|
||||||
expect(batches).toBe(1); |
|
||||||
stop(); |
|
||||||
}); |
|
||||||
}); |
|
@ -1,148 +0,0 @@ |
|||||||
import { describe, it, expect } from "vitest"; |
|
||||||
import { deepSignal, getDeepSignalRootId, peek } from "../deepSignal"; |
|
||||||
import { |
|
||||||
watch, |
|
||||||
__traverseCount, |
|
||||||
__resetTraverseCount, |
|
||||||
traverse, |
|
||||||
} from "../watch"; |
|
||||||
import { effect } from "../core"; |
|
||||||
|
|
||||||
describe("watch advanced", () => { |
|
||||||
it("basic patch watcher fires on deep mutations", async () => { |
|
||||||
const st = deepSignal({ a: { b: { c: 1 } } }); |
|
||||||
let batches: number = 0; |
|
||||||
watch(st, ({ patches }) => { |
|
||||||
if (patches.length) batches++; |
|
||||||
}); |
|
||||||
st.a.b.c = 2; |
|
||||||
st.a.b = { c: 3 } as any; |
|
||||||
await Promise.resolve(); |
|
||||||
expect(batches).toBeGreaterThan(0); |
|
||||||
}); |
|
||||||
|
|
||||||
// multi-source value mode removed; patch-only now - skip equivalent
|
|
||||||
|
|
||||||
// getter source value mode removed in patch-only watcher
|
|
||||||
|
|
||||||
it("watch once option still stops after first batch", async () => { |
|
||||||
const st = deepSignal({ a: 1 }); |
|
||||||
let count = 0; |
|
||||||
watch( |
|
||||||
st, |
|
||||||
() => { |
|
||||||
count++; |
|
||||||
}, |
|
||||||
{ once: true, immediate: true } |
|
||||||
); |
|
||||||
st.a = 2; |
|
||||||
st.a = 3; |
|
||||||
await Promise.resolve(); |
|
||||||
expect(count).toBe(1); |
|
||||||
}); |
|
||||||
|
|
||||||
// observe value mode removed; observe is alias of watch
|
|
||||||
}); |
|
||||||
|
|
||||||
describe("patches & root ids", () => { |
|
||||||
it("root ids are unique", () => { |
|
||||||
const a = deepSignal({}); |
|
||||||
const b = deepSignal({}); |
|
||||||
expect(getDeepSignalRootId(a)).not.toBe(getDeepSignalRootId(b)); |
|
||||||
}); |
|
||||||
|
|
||||||
// legacy watchPatches API removed; patch mode only valid for deepSignal roots
|
|
||||||
it("watch throws on non-deepSignal input", () => { |
|
||||||
expect(() => watch({} as any, () => {})).toThrow(); |
|
||||||
}); |
|
||||||
|
|
||||||
it("Map unsupported does not emit patches", async () => { |
|
||||||
const m = new Map<string, number>(); |
|
||||||
const st = deepSignal({ m }); |
|
||||||
const patches: any[] = []; |
|
||||||
const { stopListening: stop } = watch(st, ({ patches: batch }) => |
|
||||||
patches.push(batch) |
|
||||||
); |
|
||||||
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 { stopListening: stop } = watch(st, ({ patches }) => |
|
||||||
paths.push(...patches.map((pp: any) => 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 { stopListening: stop } = watch(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 { stopListening: stop } = watch(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); |
|
||||||
}); |
|
||||||
}); |
|
@ -1,91 +0,0 @@ |
|||||||
import { describe, expect, it } from "vitest"; |
|
||||||
import { deepSignal } from "../deepSignal"; |
|
||||||
import { watch } from "../watch"; |
|
||||||
import { watchEffect } from "../watchEffect"; |
|
||||||
|
|
||||||
describe("watch", () => { |
|
||||||
it("watch immediate", () => { |
|
||||||
const store = deepSignal({ |
|
||||||
userinfo: { |
|
||||||
name: "tom", |
|
||||||
}, |
|
||||||
}); |
|
||||||
let val!: string; |
|
||||||
watch( |
|
||||||
store, |
|
||||||
({ newValue }) => { |
|
||||||
val = newValue.userinfo.name; |
|
||||||
}, |
|
||||||
{ immediate: true } |
|
||||||
); |
|
||||||
expect(val).toEqual("tom"); |
|
||||||
}); |
|
||||||
it("watch deep", () => { |
|
||||||
const store = deepSignal({ |
|
||||||
userinfo: { |
|
||||||
name: "tom", |
|
||||||
}, |
|
||||||
}); |
|
||||||
let val!: string; |
|
||||||
watch( |
|
||||||
store, |
|
||||||
({ newValue }) => { |
|
||||||
val = newValue.userinfo.name; |
|
||||||
}, |
|
||||||
{ immediate: true } |
|
||||||
); |
|
||||||
let value2!: string; |
|
||||||
watch( |
|
||||||
store, |
|
||||||
({ newValue }) => { |
|
||||||
value2 = newValue.userinfo.name; |
|
||||||
}, |
|
||||||
{ immediate: true } |
|
||||||
); |
|
||||||
expect(val).toEqual("tom"); |
|
||||||
store.userinfo.name = "jon"; |
|
||||||
// patch delivery async (microtask)
|
|
||||||
return Promise.resolve().then(() => { |
|
||||||
expect(val).toEqual("jon"); |
|
||||||
// With refactored watch using native effect, shallow watcher now also updates root reference
|
|
||||||
expect(value2).toEqual("jon"); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
it("watch once", () => { |
|
||||||
const store = deepSignal({ |
|
||||||
userinfo: { |
|
||||||
name: "tom", |
|
||||||
}, |
|
||||||
}); |
|
||||||
let val!: string; |
|
||||||
watch( |
|
||||||
store, |
|
||||||
({ newValue }) => { |
|
||||||
val = newValue.userinfo.name; |
|
||||||
}, |
|
||||||
{ immediate: true, once: true } |
|
||||||
); |
|
||||||
|
|
||||||
expect(val).toEqual("tom"); |
|
||||||
store.userinfo.name = "jon"; |
|
||||||
// once watcher shouldn't update after first run
|
|
||||||
expect(val).toEqual("tom"); |
|
||||||
}); |
|
||||||
|
|
||||||
it("watch effect", () => { |
|
||||||
const store = deepSignal({ |
|
||||||
userinfo: { |
|
||||||
name: "tom", |
|
||||||
}, |
|
||||||
}); |
|
||||||
let x = undefined; |
|
||||||
watchEffect(() => { |
|
||||||
x = store.userinfo.name; |
|
||||||
}); |
|
||||||
|
|
||||||
expect(x).toEqual("tom"); |
|
||||||
store.userinfo.name = "jon"; |
|
||||||
expect(x).toEqual("jon"); |
|
||||||
}); |
|
||||||
}); |
|
@ -1,336 +0,0 @@ |
|||||||
import { describe, it, expect } from "vitest"; |
|
||||||
import { |
|
||||||
deepSignal, |
|
||||||
setSetEntrySyntheticId, |
|
||||||
addWithId, |
|
||||||
DeepPatch, |
|
||||||
} from "../deepSignal"; |
|
||||||
import { watch, observe } from "../watch"; |
|
||||||
|
|
||||||
describe("watch (patch mode)", () => { |
|
||||||
it("emits set patches with correct paths and batching", async () => { |
|
||||||
const state = deepSignal({ a: { b: 1 }, arr: [1, { x: 2 }] }); |
|
||||||
const received: DeepPatch[][] = []; |
|
||||||
const { stopListening: stop } = watch(state, ({ patches }) => { |
|
||||||
received.push(patches); |
|
||||||
}); |
|
||||||
state.a.b = 2; |
|
||||||
(state.arr[1] as any).x = 3; |
|
||||||
state.arr.push(5); |
|
||||||
await Promise.resolve(); |
|
||||||
expect(received.length).toBe(1); |
|
||||||
const batch = received[0]; |
|
||||||
const paths = batch.map((p) => p.path.join(".")).sort(); |
|
||||||
expect(paths).toContain("a.b"); |
|
||||||
expect(paths).toContain("arr.1.x"); |
|
||||||
expect(paths).toContain("arr.2"); |
|
||||||
const addOps = batch.filter((p) => p.op === "add").length; |
|
||||||
expect(addOps).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: DeepPatch[][] = []; |
|
||||||
const { stopListening: stop } = watch(state, ({ patches }) => |
|
||||||
out.push(patches) |
|
||||||
); |
|
||||||
delete state.a.b; |
|
||||||
delete state.c; |
|
||||||
await Promise.resolve(); |
|
||||||
expect(out.length).toBe(1); |
|
||||||
const [batch] = out; |
|
||||||
const deletePatches = batch.filter((p) => p.op === "remove"); |
|
||||||
const delPaths = deletePatches.map((p) => p.path.join(".")).sort(); |
|
||||||
expect(delPaths).toEqual(["a.b", "c"]); |
|
||||||
deletePatches.forEach((p: any) => expect(p.value).toBeUndefined()); |
|
||||||
stop(); |
|
||||||
}); |
|
||||||
|
|
||||||
it("observe patch mode mirrors watch patch mode", async () => { |
|
||||||
const state = deepSignal({ a: 1 }); |
|
||||||
const wp: DeepPatch[][] = []; |
|
||||||
const ob: DeepPatch[][] = []; |
|
||||||
const { stopListening: stop1 } = watch(state, ({ patches }) => |
|
||||||
wp.push(patches) |
|
||||||
); |
|
||||||
const { stopListening: stop2 } = observe(state, ({ patches }) => |
|
||||||
ob.push(patches) |
|
||||||
); |
|
||||||
state.a = 2; |
|
||||||
await Promise.resolve(); |
|
||||||
expect(wp.length).toBe(1); |
|
||||||
expect(ob.length).toBe(1); |
|
||||||
expect(wp[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: DeepPatch[][] = []; |
|
||||||
const { stopListening: stop } = watch(a, ({ patches }) => |
|
||||||
out.push(patches) |
|
||||||
); |
|
||||||
b.y = 3; |
|
||||||
a.x = 2; |
|
||||||
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: DeepPatch[][] = []; |
|
||||||
const { stopListening: stop } = watch(state, ({ patches }) => |
|
||||||
batches.push(patches) |
|
||||||
); |
|
||||||
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(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: DeepPatch[][] = []; |
|
||||||
const { stopListening: stop } = watch(state, ({ patches: batch }) => |
|
||||||
patches.push(batch) |
|
||||||
); |
|
||||||
state.root.child = { level: { value: 1 } }; |
|
||||||
state.root.child.level.value = 2; |
|
||||||
await Promise.resolve(); |
|
||||||
const flat = patches.flat().map((p) => p.path.join(".")); |
|
||||||
expect(flat).toContain("root.child"); |
|
||||||
expect(flat).toContain("root.child.level.value"); |
|
||||||
stop(); |
|
||||||
}); |
|
||||||
|
|
||||||
it("emits structural patches for sets of sets", 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: DeepPatch[][] = []; |
|
||||||
const { stopListening: stop } = watch(state, ({ patches }) => |
|
||||||
batches.push(patches) |
|
||||||
); |
|
||||||
const innerB = new Set<any>([{ id: "node2", x: 5 }]); |
|
||||||
state.graph.add(innerB); |
|
||||||
([...innerA][0] as any).x = 2; |
|
||||||
await Promise.resolve(); |
|
||||||
const pathStrings = batches.flat().map((p) => p.path.join(".")); |
|
||||||
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 collected: DeepPatch[][] = []; |
|
||||||
const { stopListening: stop } = watch(st, ({ patches }) => |
|
||||||
collected.push(patches) |
|
||||||
); |
|
||||||
let proxied: any; |
|
||||||
for (const e of st.bag.values()) { |
|
||||||
proxied = e; |
|
||||||
e.data.val; |
|
||||||
} |
|
||||||
proxied.data.val = 2; |
|
||||||
await Promise.resolve(); |
|
||||||
const flat = collected.flat().map((p: DeepPatch) => p.path.join(".")); |
|
||||||
expect(flat.some((p: string) => 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 collected2: DeepPatch[][] = []; |
|
||||||
const { stopListening: stop } = watch(state, ({ patches }) => |
|
||||||
collected2.push(patches) |
|
||||||
); |
|
||||||
addWithId(state.s as any, node, "custom123"); |
|
||||||
await Promise.resolve(); |
|
||||||
const flat = collected2.flat().map((p: DeepPatch) => p.path.join(".")); |
|
||||||
expect(flat.some((p: string) => p === "s.custom123")).toBe(true); |
|
||||||
stop(); |
|
||||||
}); |
|
||||||
|
|
||||||
describe("Set", () => { |
|
||||||
it("emits single 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: DeepPatch[][] = []; |
|
||||||
const { stopListening: stop } = watch(st, ({ patches }) => |
|
||||||
batches.push(patches) |
|
||||||
); |
|
||||||
st.s.clear(); |
|
||||||
await Promise.resolve(); |
|
||||||
const all = batches.flat().map((p) => p.path.join(".")); |
|
||||||
expect(all).toEqual(["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: DeepPatch[][] = []; |
|
||||||
const { stopListening: stop } = watch(st, ({ patches: batch }) => |
|
||||||
patches.push(batch) |
|
||||||
); |
|
||||||
st.s.add(obj); |
|
||||||
st.s.delete(obj); |
|
||||||
await Promise.resolve(); |
|
||||||
const all = patches |
|
||||||
.flat() |
|
||||||
.filter((p) => p.op === "remove") |
|
||||||
.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: DeepPatch[][] = []; |
|
||||||
const { stopListening: stop } = watch(st, ({ patches: batch }) => |
|
||||||
patches.push(batch) |
|
||||||
); |
|
||||||
st.s.add(1); |
|
||||||
await Promise.resolve(); |
|
||||||
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: DeepPatch[][] = []; |
|
||||||
const { stopListening: stop } = watch(st, ({ patches: batch }) => |
|
||||||
patches.push(batch) |
|
||||||
); |
|
||||||
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: DeepPatch[][] = []; |
|
||||||
const { stopListening: stop } = watch(st, ({ patches: batch }) => |
|
||||||
patches.push(batch) |
|
||||||
); |
|
||||||
const ret = addWithId(st.s as any, 5, "ignored"); |
|
||||||
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: DeepPatch[][] = []; |
|
||||||
const { stopListening: stop } = watch(st, ({ patches: batch }) => |
|
||||||
patches.push(batch) |
|
||||||
); |
|
||||||
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: DeepPatch[][] = []; |
|
||||||
const { stopListening: stop } = watch(st, ({ patches }) => |
|
||||||
batches.push(patches) |
|
||||||
); |
|
||||||
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: DeepPatch[][] = []; |
|
||||||
const { stopListening: stop } = watch(st, ({ patches }) => |
|
||||||
batches.push(patches) |
|
||||||
); |
|
||||||
raw.data.x = 2; |
|
||||||
await Promise.resolve(); |
|
||||||
const afterRaw = batches.flat().map((p) => p.path.join(".")); |
|
||||||
expect(afterRaw.some((p) => p.endsWith("id1.data.x"))).toBe(false); |
|
||||||
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: DeepPatch[][] = []; |
|
||||||
const { stopListening: stop } = watch(st, ({ patches: batch }) => |
|
||||||
patches.push(batch) |
|
||||||
); |
|
||||||
st.s.add(a1); |
|
||||||
st.s.add(a2); |
|
||||||
await Promise.resolve(); |
|
||||||
const keys = patches |
|
||||||
.flat() |
|
||||||
.filter((p) => p.op === "add") |
|
||||||
.map((p) => p.path.slice(-1)[0]); |
|
||||||
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: DeepPatch[][] = []; |
|
||||||
const { stopListening: stop } = watch(st, ({ patches }) => |
|
||||||
batches.push(patches) |
|
||||||
); |
|
||||||
st.arr.splice(1, 1, 99, 100); |
|
||||||
st.arr.unshift(0); |
|
||||||
st.arr.shift(); |
|
||||||
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: DeepPatch[][] = []; |
|
||||||
const { stopListening: stop } = watch(st, ({ patches }) => |
|
||||||
batches.push(patches) |
|
||||||
); |
|
||||||
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,41 +0,0 @@ |
|||||||
export const objectToString: typeof Object.prototype.toString = |
|
||||||
Object.prototype.toString |
|
||||||
export const toTypeString = (value: unknown): string => |
|
||||||
objectToString.call(value) |
|
||||||
const hasOwnProperty = Object.prototype.hasOwnProperty |
|
||||||
export const hasOwn = ( |
|
||||||
val: object, |
|
||||||
key: string | symbol, |
|
||||||
): key is keyof typeof val => hasOwnProperty.call(val, key) |
|
||||||
|
|
||||||
export const isArray: typeof Array.isArray = Array.isArray |
|
||||||
export const isMap = (val: unknown): val is Map<any, any> => |
|
||||||
toTypeString(val) === '[object Map]' |
|
||||||
export const isSet = (val: unknown): val is Set<any> => |
|
||||||
toTypeString(val) === '[object Set]' |
|
||||||
|
|
||||||
export const isDate = (val: unknown): val is Date => |
|
||||||
toTypeString(val) === '[object Date]' |
|
||||||
export const isRegExp = (val: unknown): val is RegExp => |
|
||||||
toTypeString(val) === '[object RegExp]' |
|
||||||
export const isFunction = (val: unknown): val is Function => |
|
||||||
typeof val === 'function' |
|
||||||
export const isString = (val: unknown): val is string => typeof val === 'string' |
|
||||||
export const isSymbol = (val: unknown): val is symbol => typeof val === 'symbol' |
|
||||||
export const isObject = (val: unknown): val is Record<any, any> => |
|
||||||
val !== null && typeof val === 'object' |
|
||||||
|
|
||||||
export const isPromise = <T = any>(val: unknown): val is Promise<T> => { |
|
||||||
return ( |
|
||||||
(isObject(val) || isFunction(val)) && |
|
||||||
isFunction((val as any).then) && |
|
||||||
isFunction((val as any).catch) |
|
||||||
) |
|
||||||
} |
|
||||||
export const isPlainObject = (val: unknown): val is object => |
|
||||||
toTypeString(val) === '[object Object]' |
|
||||||
|
|
||||||
export const hasChanged = (value: any, oldValue: any): boolean => |
|
||||||
!Object.is(value, oldValue) |
|
||||||
|
|
||||||
export function NOOP() {} |
|
@ -1,188 +0,0 @@ |
|||||||
import { isObject, isPlainObject, isSet, isMap, isArray } from "./utils"; |
|
||||||
import { isSignal } from "./core"; |
|
||||||
import { |
|
||||||
isDeepSignal, |
|
||||||
subscribeDeepMutations, |
|
||||||
getDeepSignalRootId, |
|
||||||
DeepPatch, |
|
||||||
} from "./deepSignal"; |
|
||||||
import { ReactiveFlags } from "./contents"; |
|
||||||
|
|
||||||
/** Function provided to register a disposer (runs before next callback or on stop). */ |
|
||||||
export type RegisterCleanup = (cleanupFn: () => void) => void; |
|
||||||
/** Signature for watchEffect style sources receiving the cleanup registrar. */ |
|
||||||
export type WatchEffect = (registerCleanup: RegisterCleanup) => void; |
|
||||||
|
|
||||||
/** Options for {@link watch}. */ |
|
||||||
export interface WatchOptions { |
|
||||||
/** Trigger the callback immediately with the current value (default: false). */ |
|
||||||
immediate?: boolean; |
|
||||||
/** Auto-stop the watcher after the first callback run that delivers patches (or immediate call if no patches). */ |
|
||||||
once?: boolean; |
|
||||||
/** Allow legacy/unknown options (ignored) to avoid hard breaks while migrating. */ |
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
[legacy: string]: any; |
|
||||||
} |
|
||||||
|
|
||||||
export interface WatchPatchEvent<Root = any> { |
|
||||||
/** Patch batch that triggered this callback (may be empty for immediate). */ |
|
||||||
patches: DeepPatch[]; |
|
||||||
/** Previous snapshot (deep-cloned) of the root value before these patches. Undefined on first call. */ |
|
||||||
oldValue: Root | undefined; |
|
||||||
/** Current root value (live proxy). */ |
|
||||||
newValue: Root; |
|
||||||
} |
|
||||||
|
|
||||||
export type WatchPatchCallback<Root = any> = ( |
|
||||||
event: WatchPatchEvent<Root> |
|
||||||
) => any; |
|
||||||
|
|
||||||
// Internal helper kept for external compatibility.
|
|
||||||
export const remove = <T>(arr: T[], el: T): void => { |
|
||||||
const i = arr.indexOf(el); |
|
||||||
if (i > -1) arr.splice(i, 1); |
|
||||||
}; |
|
||||||
|
|
||||||
/** Observe patch batches on a deep signal root. */ |
|
||||||
export function watch<Root = any>( |
|
||||||
source: Root, |
|
||||||
callback: WatchPatchCallback<Root>, |
|
||||||
options: WatchOptions = {} |
|
||||||
) { |
|
||||||
if (!isDeepSignal(source)) { |
|
||||||
throw new Error( |
|
||||||
"watch() now only supports deepSignal roots (patch mode only)" |
|
||||||
); |
|
||||||
} |
|
||||||
const { immediate, once } = options; |
|
||||||
|
|
||||||
const rootId = getDeepSignalRootId(source as any)!; |
|
||||||
|
|
||||||
let active = true; |
|
||||||
let cleanup: (() => void) | undefined; |
|
||||||
const registerCleanup: RegisterCleanup = (fn) => { |
|
||||||
cleanup = fn; |
|
||||||
}; |
|
||||||
const runCleanup = () => { |
|
||||||
if (cleanup) { |
|
||||||
try { |
|
||||||
cleanup(); |
|
||||||
} catch { |
|
||||||
/* ignore */ |
|
||||||
} finally { |
|
||||||
cleanup = undefined; |
|
||||||
} |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
// Deep clone snapshot helper (JSON clone sufficient for typical reactive plain data)
|
|
||||||
const clone = (v: any) => { |
|
||||||
try { |
|
||||||
return JSON.parse(JSON.stringify(v)); |
|
||||||
} catch { |
|
||||||
return undefined as any; |
|
||||||
} |
|
||||||
}; |
|
||||||
let lastSnapshot: Root | undefined = clone(source); |
|
||||||
|
|
||||||
const stopListening = () => { |
|
||||||
if (!active) return; |
|
||||||
active = false; |
|
||||||
runCleanup(); |
|
||||||
unsubscribe && unsubscribe(); |
|
||||||
}; |
|
||||||
|
|
||||||
const deliver = (patches: DeepPatch[]) => { |
|
||||||
if (!active) return; |
|
||||||
runCleanup(); |
|
||||||
const prev = lastSnapshot; |
|
||||||
const next = source as any as Root; // live proxy
|
|
||||||
try { |
|
||||||
callback({ |
|
||||||
patches, |
|
||||||
oldValue: prev, |
|
||||||
newValue: next, |
|
||||||
}); |
|
||||||
} finally { |
|
||||||
if (active) lastSnapshot = clone(next); |
|
||||||
if (once) stopListening(); |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
const unsubscribe = subscribeDeepMutations(rootId, (patches) => { |
|
||||||
if (!patches.length) return; // ignore empty batches
|
|
||||||
deliver(patches); |
|
||||||
}); |
|
||||||
|
|
||||||
if (immediate) { |
|
||||||
// Immediate call with empty patch list (snapshot only)
|
|
||||||
deliver([]); |
|
||||||
} |
|
||||||
|
|
||||||
return { |
|
||||||
/** Stop listening to future patch batches; idempotent. */ |
|
||||||
stopListening, |
|
||||||
/** Register a cleanup callback run before the next invocation / stop. */ |
|
||||||
registerCleanup, |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
// observe alias
|
|
||||||
export function observe( |
|
||||||
source: any, |
|
||||||
cb: WatchPatchCallback, |
|
||||||
options?: WatchOptions |
|
||||||
) { |
|
||||||
return watch(source, cb, options); |
|
||||||
} |
|
||||||
|
|
||||||
// Instrumentation counter for performance tests (number of traverse invocations)
|
|
||||||
/** Instrumentation counter tracking total `traverse()` invocations (used in tests). */ |
|
||||||
export let __traverseCount = 0; // retained for external tooling/tests although watch no longer uses traversal
|
|
||||||
/** 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( |
|
||||||
value: unknown, |
|
||||||
depth: number = Infinity, |
|
||||||
seen?: Set<unknown> |
|
||||||
): unknown { |
|
||||||
__traverseCount++; |
|
||||||
if (depth <= 0 || !isObject(value) || (value as any)[ReactiveFlags.SKIP]) { |
|
||||||
return value; |
|
||||||
} |
|
||||||
|
|
||||||
seen = seen || new Set(); |
|
||||||
if (seen.has(value)) { |
|
||||||
return value; |
|
||||||
} |
|
||||||
seen.add(value); |
|
||||||
depth--; |
|
||||||
if (isSignal(value)) { |
|
||||||
traverse((value as any)(), depth, seen); |
|
||||||
} else if (isArray(value)) { |
|
||||||
for (let i = 0; i < value.length; i++) { |
|
||||||
traverse(value[i], depth, seen); |
|
||||||
} |
|
||||||
} else if (isSet(value) || isMap(value)) { |
|
||||||
value.forEach((v: any) => { |
|
||||||
traverse(v, depth, seen); |
|
||||||
}); |
|
||||||
} else if (isPlainObject(value)) { |
|
||||||
for (const key in value) { |
|
||||||
traverse(value[key], depth, seen); |
|
||||||
} |
|
||||||
for (const key of Object.getOwnPropertySymbols(value)) { |
|
||||||
if (Object.prototype.propertyIsEnumerable.call(value, key)) { |
|
||||||
traverse(value[key as any], depth, seen); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
return value; |
|
||||||
} |
|
@ -1,32 +0,0 @@ |
|||||||
import { effect as coreEffect } from "./core"; |
|
||||||
/** Run a reactive function and re-run on its dependencies; supports cleanup. */ |
|
||||||
export function watchEffect( |
|
||||||
fn: (registerCleanup?: (cleanup: () => void) => void) => void |
|
||||||
) { |
|
||||||
let cleanup: (() => void) | undefined; |
|
||||||
const registerCleanup = (cb: () => void) => { |
|
||||||
cleanup = cb; |
|
||||||
}; |
|
||||||
const stop = coreEffect(() => { |
|
||||||
if (cleanup) { |
|
||||||
try { |
|
||||||
cleanup(); |
|
||||||
} catch { |
|
||||||
/* ignore */ |
|
||||||
} finally { |
|
||||||
cleanup = undefined; |
|
||||||
} |
|
||||||
} |
|
||||||
fn(registerCleanup); |
|
||||||
}); |
|
||||||
return () => { |
|
||||||
if (cleanup) { |
|
||||||
try { |
|
||||||
cleanup(); |
|
||||||
} catch { |
|
||||||
/* ignore */ |
|
||||||
} |
|
||||||
} |
|
||||||
stop(); |
|
||||||
}; |
|
||||||
} |
|
@ -0,0 +1,5 @@ |
|||||||
|
import { vitePreprocess } from "@astrojs/svelte"; |
||||||
|
|
||||||
|
export default { |
||||||
|
preprocess: vitePreprocess(), |
||||||
|
}; |
@ -1,25 +1,8 @@ |
|||||||
{ |
{ |
||||||
|
"extends": "astro/tsconfigs/strict", |
||||||
"compilerOptions": { |
"compilerOptions": { |
||||||
"target": "ES2020", |
"jsx": "react-jsx", |
||||||
"useDefineForClassFields": true, |
"jsxImportSource": "react", |
||||||
"module": "ESNext", |
"baseUrl": "." |
||||||
"lib": [ |
} |
||||||
"ES2020" |
|
||||||
], |
|
||||||
"skipLibCheck": true, |
|
||||||
/* Bundler mode */ |
|
||||||
"moduleResolution": "bundler", |
|
||||||
"allowImportingTsExtensions": true, |
|
||||||
"resolveJsonModule": true, |
|
||||||
"isolatedModules": false, |
|
||||||
"noEmit": true, |
|
||||||
/* Linting */ |
|
||||||
"strict": true, |
|
||||||
"noUnusedLocals": true, |
|
||||||
"noUnusedParameters": true, |
|
||||||
"noFallthroughCasesInSwitch": true, |
|
||||||
}, |
|
||||||
"include": [ |
|
||||||
"src" |
|
||||||
] |
|
||||||
} |
} |
||||||
|
@ -1,14 +0,0 @@ |
|||||||
import type { Options } from 'tsup' |
|
||||||
|
|
||||||
export const tsup: Options = { |
|
||||||
entry: [ |
|
||||||
'src/index.ts', |
|
||||||
], |
|
||||||
format: ['esm', 'cjs'], |
|
||||||
dts: true, |
|
||||||
splitting: true, |
|
||||||
clean: true, |
|
||||||
shims: false, |
|
||||||
minify: false, |
|
||||||
external: ['alien-signals'], |
|
||||||
} |
|
Loading…
Reference in new issue