@blu3ph4ntom/inval 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release.
6
+
7
+ ### Features
8
+ - `input()` — Create leaf nodes with get/set/invalidate
9
+ - `node()` — Create computed nodes with dependencies and lazy recompute
10
+ - `batch()` — Batch multiple set operations, returns changed set
11
+ - `dispose()` — Disconnect nodes from the graph
12
+ - `why()` — Trace invalidation paths
13
+ - `ancestors()` — Get all upstream dependencies
14
+ - `descendants()` — Get all downstream dependents
15
+ - `inspect()` — Debug node state
16
+ - `toDot()` — Export graph as Graphviz DOT
17
+ - `stats()` — Graph statistics
18
+ - Cycle detection at construction time
19
+ - Zero dependencies, pure TypeScript
20
+ - ESM, CJS, and IIFE builds
21
+ - TypeScript type definitions
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,431 @@
1
+ # @blu3ph4ntom/inval
2
+
3
+ **Deterministic, incremental layout invalidation engine for production.**
4
+
5
+ Not a framework. Not a renderer. The missing primitive for layout-aware applications.
6
+
7
+ ```
8
+ naive: 50 widgets, change 1 width 417K ops/s
9
+ inval: 50 widgets, change 1 width 4,230K ops/s (10x faster)
10
+ ```
11
+
12
+ ---
13
+
14
+ ## Why Enterprise Teams Choose Inval
15
+
16
+ ### The Problem Nobody Talks About
17
+
18
+ Every company building interactive UIs hits this wall:
19
+
20
+ 1. **Dashboards lag on resize** — Resize a sidebar, watch the entire dashboard stutter
21
+ 2. **Virtualized lists jitter** — Scroll position jumps when dynamic-height rows load
22
+ 3. **Responsive layouts cost too much** — Every breakpoint requires careful memoization
23
+ 4. **Performance debugging is guesswork** — "Why did this re-render?" has no answers
24
+
25
+ **The root cause:** There's no abstraction for "what geometry changed, and what depends on it?"
26
+
27
+ ### Why Current Solutions Fail
28
+
29
+ | Approach | Enterprise Problem |
30
+ |----------|---------------------|
31
+ | React re-renders | Entire component trees re-render on any change |
32
+ | Memo/useMemo | Fragile, easy to miss a dependency, no debug tools |
33
+ | Signals libraries | State-focused, framework-coupled, no layout semantics |
34
+ | Virtualization libs | Solve rendering, not dependency tracking |
35
+
36
+ **Inval gives you:**
37
+ - **Explicit dependency graphs** — You declare what depends on what
38
+ - **Debug tools** — `why(node)` tells you exactly why something recomputed
39
+ - **Production guarantees** — Deterministic, tested, zero external deps
40
+ - **Framework agnostic** — Works with React, Vue, Svelte, vanilla JS
41
+
42
+ ---
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ npm install @blu3ph4ntom/inval
48
+ bun add @blu3ph4ntom/inval
49
+ pnpm add @blu3ph4ntom/inval
50
+ ```
51
+
52
+ ---
53
+
54
+ ## The Inval Difference
55
+
56
+ ### Before (Naive Approach)
57
+
58
+ ```typescript
59
+ // Every width change recomputes EVERYTHING
60
+ const width = 800
61
+ const contentWidth = width - sidebar
62
+ const cardHeight = textHeight(contentWidth, cardText)
63
+ const rowOffset = index * cardHeight
64
+ const visibleRange = computeVisible(scroll, rowOffset)
65
+ // 50 widgets × 6 computations each = 300 recomputes
66
+ ```
67
+
68
+ ### After (With Inval)
69
+
70
+ ```typescript
71
+ import { input, node, batch, why } from '@blu3ph4ntom/inval'
72
+
73
+ const width = input(800)
74
+ const contentWidth = node({
75
+ dependsOn: { w: width },
76
+ compute: ({ w }) => w - sidebar
77
+ })
78
+ const cardHeight = node({
79
+ dependsOn: { cw: contentWidth },
80
+ compute: ({ cw }) => textHeight(cw, cardText)
81
+ })
82
+
83
+ width.set(600)
84
+ // Only contentWidth and cardHeight recompute
85
+ // visibleRange? NOT dirty — it doesn't depend on width
86
+ // 5 widgets × 2 computations = 10 recomputes (30x less)
87
+ ```
88
+
89
+ **You get:**
90
+ - 10x faster performance on layout changes
91
+ - Exact knowledge of what recomputed (via `why()`)
92
+ - Confidence your reflows are minimal and correct
93
+
94
+ ---
95
+
96
+ ## Quick Start
97
+
98
+ ```typescript
99
+ import { input, node, batch, why } from '@blu3ph4ntom/inval'
100
+
101
+ const width = input(800)
102
+ const height = input(600)
103
+
104
+ const area = node({
105
+ dependsOn: { w: width, h: height },
106
+ compute: ({ w, h }) => w * h
107
+ })
108
+
109
+ area.get() // 480000 — computes lazily
110
+ area.get() // 480000 — cached, zero cost
111
+
112
+ width.set(1000)
113
+ area.get() // 600000 — recomputes only what changed
114
+
115
+ why(area) // ['area', 'width'] — trace exact invalidation path
116
+ ```
117
+
118
+ ---
119
+
120
+ ## Real-World Performance
121
+
122
+ ### Dashboard: 50 Widgets, One Width Change
123
+
124
+ ```
125
+ naive: 417,000 ops/s (recomputes all 50 × 6 = 300 computations)
126
+ inval: 4,230,000 ops/s (recomputes only 10 computations)
127
+ ─────────────────────────────────────────────────────────────
128
+ 10.1x faster in production-like scenario
129
+ ```
130
+
131
+ ### Virtualized List: 1000 Items, Width Change
132
+
133
+ ```
134
+ rowHeights: recomputes (text wrapping changed)
135
+ totalHeight: recomputes (depends on rowHeights)
136
+ visibleRange: unchanged (doesn't depend on width)
137
+
138
+ vs naive: recomputes ALL three, every time
139
+ ```
140
+
141
+ ---
142
+
143
+ ## API Reference
144
+
145
+ ### `input(value)` — Leaf Node
146
+
147
+ External code sets value. Nothing computes until you ask.
148
+
149
+ ```typescript
150
+ const width = input(800)
151
+ width.get() // 800
152
+ width.set(600) // marks dependents dirty
153
+ width.invalidate() // force invalidation
154
+ width.isDirty() // false (inputs are never dirty)
155
+ width.inspect() // { id, kind, dirty, lastValue, computeCount, ... }
156
+ width.dispose() // disconnect from graph
157
+ ```
158
+
159
+ ### `node({ dependsOn, compute })` — Computed Node
160
+
161
+ Lazy recompute. Caches until dependency changes.
162
+
163
+ ```typescript
164
+ const area = node({
165
+ dependsOn: { w: width, h: height },
166
+ compute: ({ w, h }) => w * h
167
+ })
168
+
169
+ area.get() // 480000 — computes if dirty
170
+ area.get() // 480000 — cached
171
+ area.isDirty() // true after dependency change
172
+ area.invalidate() // force recompute
173
+ area.inspect() // debug info
174
+ area.dispose() // cleanup
175
+ ```
176
+
177
+ ### `batch(fn)` — Atomic Updates
178
+
179
+ Set multiple inputs, get all changed nodes.
180
+
181
+ ```typescript
182
+ const changed = batch(() => {
183
+ a.set(10)
184
+ b.set(20)
185
+ })
186
+ // changed = Set of all dirtied nodes
187
+ ```
188
+
189
+ ### `why(node)` — Debug Invalidations
190
+
191
+ ```typescript
192
+ why(cardHeight)
193
+ // ['cardHeight', 'textHeight', 'width']
194
+ // Exact path of what caused recomputation
195
+ ```
196
+
197
+ ### `ancestors(node)` / `descendants(node)` — Graph Navigation
198
+
199
+ ```typescript
200
+ ancestors(node) // all upstream nodes
201
+ descendants(node) // all downstream nodes
202
+ ```
203
+
204
+ ### `toDot(nodes)` — Graph Visualization
205
+
206
+ ```typescript
207
+ console.log(toDot([area]))
208
+ // Paste output to graphviz.online
209
+ ```
210
+
211
+ ### `stats(nodes)` — Production Monitoring
212
+
213
+ ```typescript
214
+ stats([root])
215
+ // { nodeCount, inputCount, computedCount, edgeCount, totalComputeCalls }
216
+ ```
217
+
218
+ ---
219
+
220
+ ## Enterprise Features
221
+
222
+ ### Zero Dependencies
223
+ - No runtime deps — pure TypeScript
224
+ - 51KB unpacked, 12KB packed
225
+ - Works in Node 18+, all modern browsers
226
+
227
+ ### Production Ready
228
+ - 71 tests passing
229
+ - Deterministic behavior guaranteed
230
+ - TypeScript definitions included
231
+
232
+ ### Framework Agnostic
233
+ - React, Vue, Svelte, vanilla JS
234
+ - Works with any rendering approach
235
+ - IIFE bundle for `<script>` tags
236
+
237
+ ### Debug Tools Built-In
238
+ - `why(node)` — trace invalidations
239
+ - `inspect()` — node state
240
+ - `toDot()` — visualize graphs
241
+ - `stats()` — monitor size
242
+
243
+ ---
244
+
245
+ ## Real-World Examples
246
+
247
+ ### Variable-Height Virtualized List
248
+
249
+ ```typescript
250
+ const viewportWidth = input(800)
251
+ const scrollTop = input(0)
252
+ const viewportHeight = input(600)
253
+ const items = input(generateItems(1000))
254
+
255
+ const rowHeights = node({
256
+ dependsOn: { width: viewportWidth, items },
257
+ compute: ({ width, items }) => items.map(item => {
258
+ const charsPerLine = Math.floor(width / 8)
259
+ const lines = Math.ceil(item.text.length / charsPerLine)
260
+ return lines * 20 + 16
261
+ })
262
+ })
263
+
264
+ const totalHeight = node({
265
+ dependsOn: { heights: rowHeights },
266
+ compute: ({ heights }) => heights.reduce((a, b) => a + b, 0)
267
+ })
268
+
269
+ const visibleRange = node({
270
+ dependsOn: { offsets: rowOffsets, scroll: scrollTop, viewport: viewportHeight },
271
+ compute: ({ offsets, scroll, viewport }) => { /* ... */ }
272
+ })
273
+
274
+ // Width change → rowHeights + totalHeight + visibleRange dirty
275
+ viewportWidth.set(400)
276
+
277
+ // Scroll change → ONLY visibleRange dirty
278
+ scrollTop.set(500)
279
+ rowHeights.isDirty() // false
280
+ totalHeight.isDirty() // false
281
+ ```
282
+
283
+ ### Resizable Panel Layout
284
+
285
+ ```typescript
286
+ const containerWidth = input(1200)
287
+ const sidebarRatio = input(0.25)
288
+
289
+ const sidebarWidth = node({
290
+ dependsOn: { container: containerWidth, ratio: sidebarRatio },
291
+ compute: ({ container, ratio }) => Math.floor(container * ratio)
292
+ })
293
+
294
+ const contentWidth = node({
295
+ dependsOn: { container: containerWidth, sidebar: sidebarWidth },
296
+ compute: ({ container, sidebar }) => container - sidebar
297
+ })
298
+
299
+ const columns = node({
300
+ dependsOn: { width: contentWidth },
301
+ compute: ({ width }) => Math.max(1, Math.floor(width / 300))
302
+ })
303
+
304
+ const columnWidth = node({
305
+ dependsOn: { content: contentWidth, cols: columns },
306
+ compute: ({ content, cols }) => Math.floor(content / cols)
307
+ })
308
+
309
+ // Drag sidebar: only affected downstream nodes recompute
310
+ // Resize window: entire chain recomputes
311
+ columnWidth.get() // lazy — only computes if dirty
312
+ ```
313
+
314
+ ### Dashboard with Multiple Widgets
315
+
316
+ ```typescript
317
+ const dashboardWidth = input(1200)
318
+ const dashboardHeight = input(800)
319
+ const zoomLevel = input(1)
320
+
321
+ // Widget 1: Chart (depends on width + height)
322
+ const chartWidth = node({
323
+ dependsOn: { dash: dashboardWidth },
324
+ compute: ({ dash }) => dash * 0.6
325
+ })
326
+
327
+ // Widget 2: Table (depends on width + zoom)
328
+ const tableWidth = node({
329
+ dependsOn: { dash: dashboardWidth },
330
+ compute: ({ dash }) => dash * 0.4
331
+ })
332
+ const tableRowHeight = node({
333
+ dependsOn: { zoom: zoomLevel },
334
+ compute: ({ zoom }) => 40 * zoom
335
+ })
336
+
337
+ // Zoom change: only chartScale + tableRowHeight dirty
338
+ // Width change: ALL widgets dirty
339
+ // Precise invalidation — no wasted computation
340
+ ```
341
+
342
+ ---
343
+
344
+ ## How It Works
345
+
346
+ 1. **DAG** — Nodes form a directed acyclic graph. Cycles detected at construction.
347
+ 2. **Lazy** — `node.get()` recomputes only if dirty. Returns cached value otherwise.
348
+ 3. **Incremental** — `input.set()` walks children, marks transitive dependents dirty. Nothing else.
349
+ 4. **Pure** — No DOM. You measure, you pass values in.
350
+ 5. **Zero deps** — Pure TypeScript. No external runtime dependencies.
351
+
352
+ ---
353
+
354
+ ## Performance Benchmarks
355
+
356
+ ```
357
+ input.get() cached 34,578,147 ops/s
358
+ computed.get() cached 44,267,375 ops/s
359
+ input.set() + get() cycle 1,905,488 ops/s
360
+ chain depth 3: set+get 901,201 ops/s
361
+ chain depth 10: set+get 503,872 ops/s
362
+ diamond (1->4->1): set+get 1,486,591 ops/s
363
+ 100 chains: set 1, get 1 3,742,655 ops/s
364
+ ```
365
+
366
+ ---
367
+
368
+ ## The Evidence
369
+
370
+ Real issues from the most popular virtualization libraries:
371
+
372
+ **TanStack Virtual** (7K stars):
373
+ - [#832](https://github.com/TanStack/virtual/issues/832): "Scrolling with items of dynamic height lags and stutters"
374
+ - [#659](https://github.com/TanStack/virtual/issues/659): "Scrolling up with dynamic heights stutters"
375
+
376
+ **react-window** (17K stars):
377
+ - [#741](https://github.com/bvaughn/react-window/issues/741): "VariableSizeList causes major scroll jitters"
378
+
379
+ **react-virtuoso** (6K stars):
380
+ - [#1220](https://github.com/petyosi/react-virtuoso/issues/1220): "Dynamic height items enter persistent flickering"
381
+
382
+ **Root cause:** No dependency graph = recompute ALL on any change.
383
+
384
+ Inval solves this at the engine level.
385
+
386
+ ---
387
+
388
+ ## Why Not X?
389
+
390
+ | Tool | What it does | What it lacks |
391
+ |------|-------------|---------------|
392
+ | React | Component re-rendering | Explicit layout graph |
393
+ | Preact Signals | State reactivity | Debug tools, framework-agnostic |
394
+ | SolidJS | Best signals impl | Explicit dependency declaration |
395
+ | MobX | Observable state | Layout semantics |
396
+ | TanStack Virtual | List virtualization | Dependency tracking outside lists |
397
+ | useMemo | Memoization | No debug tools, framework-only |
398
+
399
+ ---
400
+
401
+ ## Use Cases
402
+
403
+ - Virtualized feeds with variable-height rows
404
+ - Resizable panel layouts
405
+ - Node editors
406
+ - Dashboards
407
+ - Kanban boards
408
+ - Data grids
409
+ - Timeline editors
410
+ - Chat UIs
411
+ - Whiteboards
412
+
413
+ ---
414
+
415
+ ## Demos
416
+
417
+ ```bash
418
+ git clone https://github.com/blu3ph4ntom/inval.git
419
+ cd inval
420
+ bun install
421
+
422
+ # Open in browser
423
+ open demo/pure/index.html # With inval
424
+ open demo/without-inval/index.html # Without inval (naive)
425
+ ```
426
+
427
+ ---
428
+
429
+ ## License
430
+
431
+ MIT — free for commercial use.
@@ -0,0 +1,5 @@
1
+ import type { BatchFn, Node } from './types.js';
2
+ export declare function batch(fn: BatchFn): Set<Node>;
3
+ export declare function trackInput(node: Node): void;
4
+ export declare function isInBatch(): boolean;
5
+ //# sourceMappingURL=batch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"batch.d.ts","sourceRoot":"","sources":["../src/batch.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAK/C,wBAAgB,KAAK,CAAC,EAAE,EAAE,OAAO,GAAG,GAAG,CAAC,IAAI,CAAC,CAiB5C;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,CAI3C;AAqBD,wBAAgB,SAAS,IAAI,OAAO,CAEnC"}
@@ -0,0 +1,10 @@
1
+ import type { Node } from './types.js';
2
+ export declare function toDot(nodes: Node[]): string;
3
+ export declare function stats(nodes: Node[]): {
4
+ nodeCount: number;
5
+ inputCount: number;
6
+ computedCount: number;
7
+ edgeCount: number;
8
+ totalComputeCalls: number;
9
+ };
10
+ //# sourceMappingURL=debug.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"debug.d.ts","sourceRoot":"","sources":["../src/debug.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAEtC,wBAAgB,KAAK,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,MAAM,CA4B3C;AAED,wBAAgB,KAAK,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG;IACpC,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;IAClB,aAAa,EAAE,MAAM,CAAA;IACrB,SAAS,EAAE,MAAM,CAAA;IACjB,iBAAiB,EAAE,MAAM,CAAA;CAC1B,CAmCA"}
@@ -0,0 +1,11 @@
1
+ import type { Node } from './types.js';
2
+ export declare class InvalCycleError extends Error {
3
+ constructor(path: string[]);
4
+ }
5
+ export declare function checkForCycles(roots: Node[]): void;
6
+ export declare function markDirty(source: Node): void;
7
+ export declare function why(target: Node): string[];
8
+ export declare function ancestors(node: Node): Node[];
9
+ export declare function descendants(node: Node): Node[];
10
+ export declare function graphSize(root: Node): number;
11
+ //# sourceMappingURL=graph.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["../src/graph.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAgB,MAAM,YAAY,CAAA;AAEpD,qBAAa,eAAgB,SAAQ,KAAK;gBAC5B,IAAI,EAAE,MAAM,EAAE;CAI3B;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,IAAI,CAwBlD;AAED,wBAAgB,SAAS,CAAC,MAAM,EAAE,IAAI,GAAG,IAAI,CAyB5C;AAED,wBAAgB,GAAG,CAAC,MAAM,EAAE,IAAI,GAAG,MAAM,EAAE,CAyB1C;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,EAAE,CAiB5C;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,EAAE,CAe9C;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAE5C"}