@cplieger/reactive 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +19 -0
- package/.htmlvalidate.json +28 -0
- package/.stylelintrc.json +67 -0
- package/LICENSE +692 -0
- package/README.md +120 -0
- package/cliff.toml +37 -0
- package/eslint.config.base.mjs +187 -0
- package/jsr.json +22 -0
- package/package.json +35 -0
- package/src/index.ts +25 -0
- package/src/reconcile-tree.ts +169 -0
- package/src/reconcile.ts +55 -0
- package/src/signal.ts +687 -0
- package/src/store.ts +159 -0
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# reactive
|
|
2
|
+
|
|
3
|
+
[](https://github.com/cplieger/reactive/actions/workflows/ci.yaml)
|
|
4
|
+
[](https://www.npmjs.com/package/@cplieger/reactive)
|
|
5
|
+
[](https://jsr.io/@cplieger/reactive)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
> Signals + DOM-reconciliation micro-framework for TypeScript
|
|
9
|
+
|
|
10
|
+
A standalone reactive primitives library providing fine-grained signals with automatic dependency tracking, synchronous batched effects, keyed-list DOM reconciliation, structural tree-diffing, and a typed per-key reactive store. Zero dependencies beyond the DOM API.
|
|
11
|
+
|
|
12
|
+
Mirrors semantics from @preact/signals-core and solid-js reactivity.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npx jsr add @cplieger/reactive
|
|
18
|
+
# or
|
|
19
|
+
npm i @cplieger/reactive
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import {
|
|
26
|
+
signal,
|
|
27
|
+
effect,
|
|
28
|
+
batch,
|
|
29
|
+
computed,
|
|
30
|
+
untracked,
|
|
31
|
+
subscribe,
|
|
32
|
+
isSignal,
|
|
33
|
+
isComputed,
|
|
34
|
+
} from "@cplieger/reactive";
|
|
35
|
+
|
|
36
|
+
const count = signal(0);
|
|
37
|
+
const doubled = computed(() => count.value * 2);
|
|
38
|
+
|
|
39
|
+
effect(() => {
|
|
40
|
+
console.log("count:", count.value, "doubled:", doubled.value);
|
|
41
|
+
return undefined;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
batch(() => {
|
|
45
|
+
count.value = 1;
|
|
46
|
+
count.value = 2; // effect fires once with value 2, synchronously at batch end
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Read without tracking
|
|
50
|
+
effect(() => {
|
|
51
|
+
const tracked = count.value;
|
|
52
|
+
const notTracked = untracked(() => doubled.value);
|
|
53
|
+
console.log(tracked, notTracked);
|
|
54
|
+
return undefined;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Subscribe utility
|
|
58
|
+
const dispose = subscribe(count, (v) => console.log("count changed:", v));
|
|
59
|
+
dispose();
|
|
60
|
+
|
|
61
|
+
// Type guards
|
|
62
|
+
isSignal(count); // true
|
|
63
|
+
isComputed(doubled); // true
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## API
|
|
67
|
+
|
|
68
|
+
### Signals
|
|
69
|
+
|
|
70
|
+
- `signal<T>(initial, options?): Signal<T>` — reactive value with `.value` getter/setter and `.peek()`. Options: `{ equals?: false | ((prev, next) => boolean) }`
|
|
71
|
+
- `computed<T>(fn, options?): ReadonlySignal<T>` — lazy cached derived signal (glitch-free, equality-deduped). Options: `{ equals?: false | ((prev, next) => boolean) }`
|
|
72
|
+
- `effect(fn: () => Cleanup): () => void` — auto-tracking side-effect with cleanup support. `fn` must return `undefined` or a cleanup function.
|
|
73
|
+
- `batch(fn)` — coalesce signal writes; effects flush synchronously at end of outermost batch
|
|
74
|
+
- `flushSync()` — synchronously drain pending effects (no-op inside batch)
|
|
75
|
+
- `untracked<T>(fn): T` — run fn without tracking signal reads (like Preact `untracked()` / Solid `untrack()`)
|
|
76
|
+
- `on(deps, fn, options?): () => U | undefined` — explicit dependency declaration helper (like Solid `on()`). Pass into `effect()` or `computed()`.
|
|
77
|
+
- `subscribe(signal, cb): () => void` — subscribe to a signal, calling cb immediately and on every change
|
|
78
|
+
- `isSignal(value): boolean` — type guard for signals created by `signal()`
|
|
79
|
+
- `isComputed(value): boolean` — type guard for computed signals
|
|
80
|
+
- `setEffectErrorHandler(handler): EffectErrorHandler` — set global error handler for effects; returns the previous handler
|
|
81
|
+
|
|
82
|
+
### DOM Reconciliation
|
|
83
|
+
|
|
84
|
+
- `reconcile<T>(parent, items, spec)` — keyed-list DOM reconciliation with mount/update/onRemove lifecycle
|
|
85
|
+
- `patch(parent, ...children)` — structural tree-diff, replacing a parent's children with reconciled new nodes
|
|
86
|
+
- `reconcileChildren(parent, newChildren)` — low-level child reconciliation against existing DOM
|
|
87
|
+
- `trackHandler(el, key)` — register an `on*` property for handler reconciliation during tree-diff
|
|
88
|
+
|
|
89
|
+
### Store
|
|
90
|
+
|
|
91
|
+
- `createStore<M>(): Store<M>` — typed per-key reactive store with `get`, `set`, `subscribe`, `effect`, `computed`, and `batch` methods
|
|
92
|
+
|
|
93
|
+
## Correctness guarantees
|
|
94
|
+
|
|
95
|
+
- **Glitch-freedom**: Computed signals use equality dedup (`Object.is`) — downstream effects only fire when the computed value actually changes. Diamond dependency graphs are handled correctly.
|
|
96
|
+
- **Cycle detection**: Reading a computed that is currently being computed throws `Error("Cycle detected")`.
|
|
97
|
+
- **Error caching**: If a computed's fn throws, the error is cached and rethrown on subsequent reads until dependencies change.
|
|
98
|
+
- **Computed is read-only**: Setting `.value` on a computed throws `Error("Cannot set a computed signal")`.
|
|
99
|
+
- **Synchronous batch**: `batch()` flushes effects synchronously at the end of the outermost batch (matching @preact/signals-core and solid-js).
|
|
100
|
+
|
|
101
|
+
## Design Decisions — Unsupported by Design
|
|
102
|
+
|
|
103
|
+
The following features are intentionally NOT implemented:
|
|
104
|
+
|
|
105
|
+
| Feature | Reason |
|
|
106
|
+
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
107
|
+
| **Effect ownership tree / `createRoot`** | Consumers manage disposal explicitly. Adding ownership adds ~150 LOC of complexity and changes the mental model. |
|
|
108
|
+
| **Nested effect auto-disposal** | Each effect is independent and returns its own dispose function. Compose with arrays or helper functions. |
|
|
109
|
+
| **Lazy activation / `onMount` lifecycle** | Signals are always active. Resource management is the consumer's responsibility. |
|
|
110
|
+
| **`Signal.subtle.Watcher` / notify-on-dirty** | This library IS the framework layer. The Watcher pattern is for frameworks that sit on top of a signals primitive. |
|
|
111
|
+
| **Introspection APIs** | Dev-tools concern. Not needed for production. |
|
|
112
|
+
| **Explicit computed disposal** | Computed signals are GC'd when unreferenced. No explicit teardown needed. |
|
|
113
|
+
| **SSR / server-side isolation** | Client-side library. Server usage should instantiate fresh signal graphs per request. |
|
|
114
|
+
| **Async signals / resources** | Out of scope. Use effects + manual signal writes for async data loading. |
|
|
115
|
+
| **Transactions** | Framework-level concern. Not a signals primitive. |
|
|
116
|
+
| **Custom scheduler / `setScheduler()`** | Batch is synchronous. No scheduler needed. |
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
GPL-3.0 — see [LICENSE](LICENSE).
|
package/cliff.toml
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Version-bump policy. Stable (1.x+): standard semver.
|
|
2
|
+
# feat -> minor; breaking -> major.
|
|
3
|
+
# Fresh/untagged repos start at v1.0.0 (matches "0.x is the exception" policy).
|
|
4
|
+
[bump]
|
|
5
|
+
features_always_bump_minor = true
|
|
6
|
+
breaking_always_bump_major = true
|
|
7
|
+
initial_tag = "v1.0.0"
|
|
8
|
+
no_increment_regex = "^chore|^ci|^docs|^style|^test"
|
|
9
|
+
|
|
10
|
+
[changelog]
|
|
11
|
+
header = ""
|
|
12
|
+
body = """
|
|
13
|
+
{% for group, commits in commits | group_by(attribute="group") %}
|
|
14
|
+
### {{ group | upper_first }}
|
|
15
|
+
{% for commit in commits %}
|
|
16
|
+
- {{ commit.message | upper_first }}\
|
|
17
|
+
{% endfor %}
|
|
18
|
+
{% endfor %}
|
|
19
|
+
"""
|
|
20
|
+
trim = true
|
|
21
|
+
|
|
22
|
+
[git]
|
|
23
|
+
conventional_commits = true
|
|
24
|
+
filter_unconventional = false
|
|
25
|
+
split_commits = false
|
|
26
|
+
protect_breaking_commits = false
|
|
27
|
+
tag_pattern = "v[0-9].*"
|
|
28
|
+
|
|
29
|
+
commit_parsers = [
|
|
30
|
+
{ message = "^release:", skip = true },
|
|
31
|
+
{ message = "^feat", group = "Added" },
|
|
32
|
+
{ message = "^fix", group = "Fixed" },
|
|
33
|
+
{ message = "^sec", group = "Security" },
|
|
34
|
+
{ message = "^chore\\(deps\\)", group = "Dependencies" },
|
|
35
|
+
{ message = "^chore|^ci|^docs|^style|^refactor|^perf|^test", group = "Changed" },
|
|
36
|
+
{ message = ".*", group = "Changed" },
|
|
37
|
+
]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Strict typed-linting config.
|
|
2
|
+
// References:
|
|
3
|
+
// https://typescript-eslint.io/users/configs/#strict-type-checked
|
|
4
|
+
// https://typescript-eslint.io/users/configs/#stylistic-type-checked
|
|
5
|
+
// https://typescript-eslint.io/getting-started/typed-linting
|
|
6
|
+
|
|
7
|
+
import js from "@eslint/js";
|
|
8
|
+
import tseslint from "typescript-eslint";
|
|
9
|
+
|
|
10
|
+
export default [
|
|
11
|
+
// 1. Ignore generated/build outputs and configs that don't need linting.
|
|
12
|
+
{
|
|
13
|
+
ignores: [
|
|
14
|
+
// Dependencies (any depth — vibekit's web/static-src/node_modules nests deep)
|
|
15
|
+
"**/node_modules/**",
|
|
16
|
+
// Build output / generated bundles (TS->JS, CSS bundles, etc.)
|
|
17
|
+
"**/static/**",
|
|
18
|
+
"**/static-src/dist/**",
|
|
19
|
+
"**/dist/**",
|
|
20
|
+
"**/build/**",
|
|
21
|
+
"**/.next/**",
|
|
22
|
+
"**/.cache/**",
|
|
23
|
+
"**/coverage/**",
|
|
24
|
+
// Minified / generated source
|
|
25
|
+
"**/*.min.*",
|
|
26
|
+
"**/*.gen.ts",
|
|
27
|
+
"**/*.gen.js",
|
|
28
|
+
"**/wire/*.gen.ts",
|
|
29
|
+
// Test fixtures that aren't real code
|
|
30
|
+
"**/test-stubs/**",
|
|
31
|
+
"**/__mocks__/**",
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
// 2. Strictest official preset combination (typed linting required).
|
|
35
|
+
js.configs.recommended,
|
|
36
|
+
...tseslint.configs.strictTypeChecked,
|
|
37
|
+
...tseslint.configs.stylisticTypeChecked,
|
|
38
|
+
|
|
39
|
+
// 3. Project setup — projectService auto-discovers tsconfig per file.
|
|
40
|
+
{
|
|
41
|
+
languageOptions: {
|
|
42
|
+
ecmaVersion: 2024,
|
|
43
|
+
sourceType: "module",
|
|
44
|
+
parserOptions: {
|
|
45
|
+
projectService: {
|
|
46
|
+
allowDefaultProject: ["*.test.ts", "*.property.test.ts", "fc-strict-setup.ts"],
|
|
47
|
+
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 20,
|
|
48
|
+
},
|
|
49
|
+
tsconfigRootDir: import.meta.dirname,
|
|
50
|
+
},
|
|
51
|
+
globals: {
|
|
52
|
+
// Browser
|
|
53
|
+
window: "readonly",
|
|
54
|
+
document: "readonly",
|
|
55
|
+
navigator: "readonly",
|
|
56
|
+
console: "readonly",
|
|
57
|
+
fetch: "readonly",
|
|
58
|
+
URL: "readonly",
|
|
59
|
+
URLSearchParams: "readonly",
|
|
60
|
+
setTimeout: "readonly",
|
|
61
|
+
clearTimeout: "readonly",
|
|
62
|
+
setInterval: "readonly",
|
|
63
|
+
clearInterval: "readonly",
|
|
64
|
+
requestAnimationFrame: "readonly",
|
|
65
|
+
cancelAnimationFrame: "readonly",
|
|
66
|
+
queueMicrotask: "readonly",
|
|
67
|
+
WebSocket: "readonly",
|
|
68
|
+
EventSource: "readonly",
|
|
69
|
+
AbortController: "readonly",
|
|
70
|
+
Headers: "readonly",
|
|
71
|
+
Request: "readonly",
|
|
72
|
+
Response: "readonly",
|
|
73
|
+
FormData: "readonly",
|
|
74
|
+
Blob: "readonly",
|
|
75
|
+
File: "readonly",
|
|
76
|
+
FileReader: "readonly",
|
|
77
|
+
IntersectionObserver: "readonly",
|
|
78
|
+
MutationObserver: "readonly",
|
|
79
|
+
ResizeObserver: "readonly",
|
|
80
|
+
CustomEvent: "readonly",
|
|
81
|
+
Event: "readonly",
|
|
82
|
+
EventTarget: "readonly",
|
|
83
|
+
TextEncoder: "readonly",
|
|
84
|
+
TextDecoder: "readonly",
|
|
85
|
+
crypto: "readonly",
|
|
86
|
+
btoa: "readonly",
|
|
87
|
+
atob: "readonly",
|
|
88
|
+
// Service Worker
|
|
89
|
+
self: "readonly",
|
|
90
|
+
ServiceWorkerGlobalScope: "readonly",
|
|
91
|
+
clients: "readonly",
|
|
92
|
+
// Test runner globals (vitest auto-injected)
|
|
93
|
+
describe: "readonly",
|
|
94
|
+
it: "readonly",
|
|
95
|
+
test: "readonly",
|
|
96
|
+
expect: "readonly",
|
|
97
|
+
beforeAll: "readonly",
|
|
98
|
+
beforeEach: "readonly",
|
|
99
|
+
afterAll: "readonly",
|
|
100
|
+
afterEach: "readonly",
|
|
101
|
+
vi: "readonly",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
rules: {
|
|
105
|
+
// Allow `_`-prefixed unused names.
|
|
106
|
+
"@typescript-eslint/no-unused-vars": [
|
|
107
|
+
"error",
|
|
108
|
+
{
|
|
109
|
+
argsIgnorePattern: "^_",
|
|
110
|
+
varsIgnorePattern: "^_",
|
|
111
|
+
caughtErrorsIgnorePattern: "^_",
|
|
112
|
+
destructuredArrayIgnorePattern: "^_",
|
|
113
|
+
ignoreRestSiblings: true,
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
// Enforce `import type {...}` for types.
|
|
117
|
+
"@typescript-eslint/consistent-type-imports": [
|
|
118
|
+
"error",
|
|
119
|
+
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
|
|
120
|
+
],
|
|
121
|
+
// Discourage `any`, prefer `unknown`.
|
|
122
|
+
"@typescript-eslint/no-explicit-any": "error",
|
|
123
|
+
// Avoid silent fall-through bugs in async event handlers.
|
|
124
|
+
"@typescript-eslint/no-misused-promises": [
|
|
125
|
+
"error",
|
|
126
|
+
{ checksVoidReturn: { attributes: false } },
|
|
127
|
+
],
|
|
128
|
+
// Prefer literal numeric/string template parts (catches accidental coercion).
|
|
129
|
+
"@typescript-eslint/restrict-template-expressions": [
|
|
130
|
+
"error",
|
|
131
|
+
{ allowNumber: true, allowBoolean: true, allowNullish: false },
|
|
132
|
+
],
|
|
133
|
+
// Console policy.
|
|
134
|
+
"no-console": ["warn", { allow: ["warn", "error"] }],
|
|
135
|
+
// Equality: enforce strict ===.
|
|
136
|
+
eqeqeq: ["error", "always", { null: "ignore" }],
|
|
137
|
+
curly: ["error", "all"],
|
|
138
|
+
"no-var": "error",
|
|
139
|
+
"prefer-const": "error",
|
|
140
|
+
"no-throw-literal": "error",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
// 4. Tests: typed but with relaxed rules (tests deliberately break invariants).
|
|
145
|
+
{
|
|
146
|
+
files: [
|
|
147
|
+
"**/*.test.ts",
|
|
148
|
+
"**/*.fuzz.test.ts",
|
|
149
|
+
"**/*.property.test.ts",
|
|
150
|
+
"**/test-helpers/**",
|
|
151
|
+
"**/__mocks__/**",
|
|
152
|
+
],
|
|
153
|
+
rules: {
|
|
154
|
+
"@typescript-eslint/no-non-null-assertion": "off",
|
|
155
|
+
"@typescript-eslint/no-explicit-any": "off",
|
|
156
|
+
"@typescript-eslint/no-unsafe-assignment": "off",
|
|
157
|
+
"@typescript-eslint/no-unsafe-member-access": "off",
|
|
158
|
+
"@typescript-eslint/no-unsafe-call": "off",
|
|
159
|
+
"@typescript-eslint/no-unsafe-argument": "off",
|
|
160
|
+
"@typescript-eslint/no-unsafe-return": "off",
|
|
161
|
+
"@typescript-eslint/no-extraneous-class": "off",
|
|
162
|
+
"@typescript-eslint/no-unnecessary-condition": "off",
|
|
163
|
+
"@typescript-eslint/no-misused-spread": "off",
|
|
164
|
+
"@typescript-eslint/unbound-method": "off",
|
|
165
|
+
"@typescript-eslint/require-await": "off",
|
|
166
|
+
"no-console": "off",
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
// 5. Generated and config files + tests: drop type-checked rules.
|
|
171
|
+
{
|
|
172
|
+
files: [
|
|
173
|
+
"**/*.gen.ts",
|
|
174
|
+
"**/wire/*.ts",
|
|
175
|
+
"vitest.config.ts",
|
|
176
|
+
"*.config.ts",
|
|
177
|
+
"*.config.mjs",
|
|
178
|
+
"*.config.js",
|
|
179
|
+
"**/*.test.ts",
|
|
180
|
+
"**/*.fuzz.test.ts",
|
|
181
|
+
"**/*.property.test.ts",
|
|
182
|
+
"fc-strict-setup.ts",
|
|
183
|
+
"test-stubs/**",
|
|
184
|
+
],
|
|
185
|
+
...tseslint.configs.disableTypeChecked,
|
|
186
|
+
},
|
|
187
|
+
];
|
package/jsr.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cplieger/reactive",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"exports": {
|
|
5
|
+
".": "./src/index.ts"
|
|
6
|
+
},
|
|
7
|
+
"publish": {
|
|
8
|
+
"exclude": [
|
|
9
|
+
"**/*.test.ts",
|
|
10
|
+
"**/__test-helpers__",
|
|
11
|
+
"**/fc-strict-setup.ts",
|
|
12
|
+
"eslint.config.mjs",
|
|
13
|
+
"tsconfig.json",
|
|
14
|
+
"tsconfig.test.json",
|
|
15
|
+
"tsconfig.tests.json",
|
|
16
|
+
"vitest.config.ts",
|
|
17
|
+
"renovate.json",
|
|
18
|
+
"package-lock.json",
|
|
19
|
+
".prettierrc.json"
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cplieger/reactive",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"typecheck": "tsgo -p tsconfig.json",
|
|
11
|
+
"typecheck:tests": "tsgo -p tsconfig.tests.json",
|
|
12
|
+
"test": "vitest --run"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@eslint/js": "10.0.1",
|
|
16
|
+
"@vitest/coverage-v8": "4.1.7",
|
|
17
|
+
"eslint": "10.4.1",
|
|
18
|
+
"fast-check": "4.8.0",
|
|
19
|
+
"happy-dom": "20.9.0",
|
|
20
|
+
"prettier": "3.8.3",
|
|
21
|
+
"typescript": "6.0.3",
|
|
22
|
+
"typescript-eslint": "8.60.0",
|
|
23
|
+
"vitest": "4.1.7"
|
|
24
|
+
},
|
|
25
|
+
"description": "Signals + DOM-reconciliation reactive core for TypeScript (zero dependencies)",
|
|
26
|
+
"license": "GPL-3.0-or-later",
|
|
27
|
+
"types": "./src/index.ts",
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/cplieger/reactive.git"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export {
|
|
2
|
+
signal,
|
|
3
|
+
effect,
|
|
4
|
+
batch,
|
|
5
|
+
flushSync,
|
|
6
|
+
setEffectErrorHandler,
|
|
7
|
+
computed,
|
|
8
|
+
untracked,
|
|
9
|
+
subscribe,
|
|
10
|
+
isSignal,
|
|
11
|
+
isComputed,
|
|
12
|
+
on,
|
|
13
|
+
} from "./signal.js";
|
|
14
|
+
export type {
|
|
15
|
+
Signal,
|
|
16
|
+
Cleanup,
|
|
17
|
+
EffectErrorHandler,
|
|
18
|
+
ReadonlySignal,
|
|
19
|
+
SignalOptions,
|
|
20
|
+
} from "./signal.js";
|
|
21
|
+
export { createStore } from "./store.js";
|
|
22
|
+
export type { Store } from "./store.js";
|
|
23
|
+
export { reconcile, KEY_ATTR } from "./reconcile.js";
|
|
24
|
+
export type { ReconcileSpec } from "./reconcile.js";
|
|
25
|
+
export { patch, reconcileChildren, trackHandler } from "./reconcile-tree.js";
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Structural tree-diff: reconcile a parent's children against new nodes.
|
|
2
|
+
// Handles attribute patching, text node updates, element reordering,
|
|
3
|
+
// and recursive child reconciliation. Keying via data-* or *-id attrs.
|
|
4
|
+
|
|
5
|
+
const handlerKeysMap = new WeakMap<HTMLElement, Set<string>>();
|
|
6
|
+
|
|
7
|
+
/** Register an on* handler property key for reconciliation tracking. */
|
|
8
|
+
export function trackHandler(el: HTMLElement, key: string): void {
|
|
9
|
+
let keys = handlerKeysMap.get(el);
|
|
10
|
+
if (!keys) {
|
|
11
|
+
keys = new Set();
|
|
12
|
+
handlerKeysMap.set(el, keys);
|
|
13
|
+
}
|
|
14
|
+
keys.add(key);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Replace a parent's children with the given nodes/strings, reconciling against existing DOM. */
|
|
18
|
+
export function patch(
|
|
19
|
+
parent: Node,
|
|
20
|
+
...children: (string | Node | DocumentFragment | null | undefined)[]
|
|
21
|
+
): void {
|
|
22
|
+
const newChildren: Node[] = [];
|
|
23
|
+
for (const child of children) {
|
|
24
|
+
if (child == null) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if ((child as Node).nodeType === 11) {
|
|
28
|
+
newChildren.push(...Array.from((child as Node).childNodes));
|
|
29
|
+
} else if (typeof child === "string") {
|
|
30
|
+
newChildren.push(document.createTextNode(child));
|
|
31
|
+
} else {
|
|
32
|
+
newChildren.push(child);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
reconcileChildren(parent, newChildren);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Reconcile a parent node's children against a new set of child nodes, patching in place. */
|
|
39
|
+
export function reconcileChildren(parent: Node, newChildren: Node[]): void {
|
|
40
|
+
const oldChildren = Array.from(parent.childNodes);
|
|
41
|
+
const oldByKey = new Map<string, Node>();
|
|
42
|
+
for (const child of oldChildren) {
|
|
43
|
+
const key = nodeKey(child);
|
|
44
|
+
if (key) {
|
|
45
|
+
oldByKey.set(key, child);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let oldIdx = 0;
|
|
50
|
+
for (let i = 0; i < newChildren.length; i++) {
|
|
51
|
+
const newChild = newChildren[i];
|
|
52
|
+
if (newChild === undefined) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const newKey = nodeKey(newChild);
|
|
56
|
+
|
|
57
|
+
let matched = newKey ? (oldByKey.get(newKey) ?? null) : null;
|
|
58
|
+
if (matched) {
|
|
59
|
+
oldByKey.delete(newKey);
|
|
60
|
+
} else if (!newKey) {
|
|
61
|
+
while (oldIdx < oldChildren.length) {
|
|
62
|
+
const oc = oldChildren[oldIdx];
|
|
63
|
+
if (oc === undefined || nodeKey(oc)) {
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
oldIdx++;
|
|
67
|
+
}
|
|
68
|
+
if (oldIdx < oldChildren.length) {
|
|
69
|
+
matched = oldChildren[oldIdx] ?? null;
|
|
70
|
+
oldIdx++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!matched) {
|
|
75
|
+
const ref = parent.childNodes.item(i);
|
|
76
|
+
parent.insertBefore(newChild, ref);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!canPatch(matched, newChild)) {
|
|
81
|
+
parent.replaceChild(newChild, matched);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const ref = parent.childNodes.item(i);
|
|
86
|
+
if (ref !== matched) {
|
|
87
|
+
parent.insertBefore(matched, ref);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (matched.nodeType === 3) {
|
|
91
|
+
if (matched.textContent !== newChild.textContent) {
|
|
92
|
+
matched.textContent = newChild.textContent;
|
|
93
|
+
}
|
|
94
|
+
} else if (matched.nodeType === 1) {
|
|
95
|
+
patchAttrs(matched as HTMLElement, newChild as HTMLElement);
|
|
96
|
+
reconcileChildren(matched, Array.from(newChild.childNodes));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
while (parent.childNodes.length > newChildren.length) {
|
|
101
|
+
const last = parent.lastChild;
|
|
102
|
+
if (last === null) {
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
last.remove();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function canPatch(oldNode: Node, newNode: Node): boolean {
|
|
110
|
+
if (oldNode.nodeType !== newNode.nodeType) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
if (oldNode.nodeType === 3) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
if (oldNode.nodeType !== 1) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
return oldNode.nodeName === newNode.nodeName;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function nodeKey(node: Node): string {
|
|
123
|
+
if (node.nodeType !== 1) {
|
|
124
|
+
return "";
|
|
125
|
+
}
|
|
126
|
+
for (const attr of (node as Element).attributes) {
|
|
127
|
+
if (attr.name.endsWith("-id")) {
|
|
128
|
+
return `${attr.name}=${attr.value}`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return "";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function patchAttrs(oldEl: HTMLElement, newEl: HTMLElement): void {
|
|
135
|
+
for (const attr of newEl.attributes) {
|
|
136
|
+
if (oldEl.getAttribute(attr.name) !== attr.value) {
|
|
137
|
+
oldEl.setAttribute(attr.name, attr.value);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const attr of Array.from(oldEl.attributes)) {
|
|
141
|
+
if (!newEl.hasAttribute(attr.name)) {
|
|
142
|
+
oldEl.removeAttribute(attr.name);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (oldEl.hidden !== newEl.hidden) {
|
|
146
|
+
oldEl.hidden = newEl.hidden;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Reconcile on* event handler properties (not reflected as attributes).
|
|
150
|
+
const newKeys = handlerKeysMap.get(newEl);
|
|
151
|
+
const oldKeys = handlerKeysMap.get(oldEl);
|
|
152
|
+
if (oldKeys) {
|
|
153
|
+
for (const key of oldKeys) {
|
|
154
|
+
if (!newKeys?.has(key)) {
|
|
155
|
+
(oldEl as unknown as Record<string, unknown>)[key] = null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (newKeys) {
|
|
160
|
+
const oldRec = oldEl as unknown as Record<string, unknown>;
|
|
161
|
+
const newRec = newEl as unknown as Record<string, unknown>;
|
|
162
|
+
for (const key of newKeys) {
|
|
163
|
+
oldRec[key] = newRec[key];
|
|
164
|
+
}
|
|
165
|
+
handlerKeysMap.set(oldEl, new Set(newKeys));
|
|
166
|
+
} else if (oldKeys) {
|
|
167
|
+
handlerKeysMap.delete(oldEl);
|
|
168
|
+
}
|
|
169
|
+
}
|
package/src/reconcile.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Keyed-list DOM reconciliation with mount/update/onRemove lifecycle.
|
|
2
|
+
// Identity-preserving: existing elements survive across renders.
|
|
3
|
+
|
|
4
|
+
/** Attribute name used to store reconciliation keys on elements. */
|
|
5
|
+
export const KEY_ATTR = "data-reconcile-key";
|
|
6
|
+
|
|
7
|
+
/** Specification for keyed-list reconciliation lifecycle callbacks. */
|
|
8
|
+
export interface ReconcileSpec<T> {
|
|
9
|
+
key: (item: T) => string;
|
|
10
|
+
mount: (item: T) => HTMLElement;
|
|
11
|
+
update?: (el: HTMLElement, item: T) => void;
|
|
12
|
+
onRemove?: (el: HTMLElement, key: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Reconcile a keyed list of items into a parent node, preserving existing elements by key. */
|
|
16
|
+
export function reconcile<T>(
|
|
17
|
+
parent: ParentNode,
|
|
18
|
+
items: readonly T[],
|
|
19
|
+
spec: ReconcileSpec<T>,
|
|
20
|
+
): void {
|
|
21
|
+
const existing = new Map<string, HTMLElement>();
|
|
22
|
+
for (let n = parent.firstChild; n !== null; n = n.nextSibling) {
|
|
23
|
+
if (n.nodeType !== 1) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const el = n as HTMLElement;
|
|
27
|
+
const k = el.getAttribute(KEY_ATTR);
|
|
28
|
+
if (k !== null) {
|
|
29
|
+
existing.set(k, el);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let target: Node | null = null;
|
|
34
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
35
|
+
const item = items[i] as T;
|
|
36
|
+
const k = spec.key(item);
|
|
37
|
+
let el = existing.get(k);
|
|
38
|
+
if (el === undefined) {
|
|
39
|
+
el = spec.mount(item);
|
|
40
|
+
el.setAttribute(KEY_ATTR, k);
|
|
41
|
+
} else {
|
|
42
|
+
existing.delete(k);
|
|
43
|
+
if (spec.update) {
|
|
44
|
+
spec.update(el, item);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
parent.insertBefore(el, target);
|
|
48
|
+
target = el;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const [k, el] of existing) {
|
|
52
|
+
spec.onRemove?.(el, k);
|
|
53
|
+
el.remove();
|
|
54
|
+
}
|
|
55
|
+
}
|