@aihu/reactive 0.2.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/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # @aihu/reactive
2
+
3
+ > **Aihu** — agentic discovery and interaction, for human purpose.
4
+
5
+ Fine-grained Proxy-backed deep reactive trees on aihu signals — lazy per-(object,key) tracking nodes, plain-assignment writes, mutate/reconcile.
6
+
7
+
8
+ <!-- BEGIN_HANDWRITTEN: prose -->
9
+ Design: [`docs/plans/2026-07-24-deep-reactivity.md`](../../docs/plans/2026-07-24-deep-reactivity.md).
10
+
11
+ A Solid-shaped node model (lazily allocated per-`(object, key)` tracking cells,
12
+ allocated on first proxy touch — tracked or not) with Vue-shaped write
13
+ ergonomics (plain `obj.key = value` assignment, no `setStore(...)` path
14
+ tuples). Built entirely on the public `@aihu/signals` API — `@aihu/signals`
15
+ is `external`, so this package adds **zero bytes** to the signals core row.
16
+
17
+ ```ts
18
+ import { reactive, mutate, unwrap } from '@aihu/reactive'
19
+ import { computed, effect } from '@aihu/signals'
20
+
21
+ const user = reactive({ name: 'Ada', address: { city: 'London' }, tags: ['math'] })
22
+
23
+ effect(() => console.log(user.address.city)) // tracks node(user,'address') + node(addr,'city')
24
+
25
+ user.address.city = 'Cambridge' // one flush → logs
26
+
27
+ mutate(user, (u) => {
28
+ // ONE flush for three writes
29
+ u.name = 'Ada L.'
30
+ u.address.city = 'Bletchley'
31
+ u.tags.push('crypto')
32
+ })
33
+
34
+ const initials = computed(() => user.name.split(' ').map((s) => s[0]).join(''))
35
+ // initials does NOT recompute when address.city changes.
36
+
37
+ localStorage.setItem('user', JSON.stringify(unwrap(user)))
38
+ ```
39
+
40
+ `reconcile(target, next, { key? })` merges a fresh payload (e.g. a hydration
41
+ snapshot or a refetch) into `target` in place — unchanged nested values keep
42
+ their proxy identity and notify nobody; only genuinely-changed paths flush.
43
+ `key` controls array-item matching (default: index; pass a property name or a
44
+ function for keyed reordering, e.g. `{ key: 'id' }`).
45
+
46
+ `@aihu/reactive/helpers` bridges the tree ↔ tuple worlds: `toSignal`/
47
+ `toSignals` lens reactive properties as `Signal` tuples, `toReactive` goes the
48
+ other way (a `Signal<object>` viewed as a reactive-looking object),
49
+ `reactivePick`/`reactiveOmit` are read-through views (no copies), and
50
+ `reactiveComputed(fn)` keeps a reactive object in sync with `fn()` via an
51
+ effect + `reconcile` — consumers reading one key only re-run when THAT key's
52
+ value actually changes.
53
+
54
+ **Non-wrappable values pass through unchanged**: `Date`, `Map`, `Set`,
55
+ `RegExp`, class instances, DOM nodes, and frozen objects are stored raw and
56
+ are replace-only — `reactive()` does not attempt to make them deeply
57
+ reactive (see the design doc §8.8/§8.9 for the reasoning).
58
+
59
+ Nothing shipped elsewhere changes: `signal`/`computed`/`effect`, and every
60
+ composable in `@aihu/use`, keep their existing contracts verbatim. This is a
61
+ purely additive new import, not new behavior on an old one.
62
+ <!-- END_HANDWRITTEN: prose -->
63
+
64
+ ## Install
65
+
66
+ <!-- BEGIN_AUTOGEN: install -->
67
+ <!-- regenerate: bun scripts/sync-readme.ts (also runs in pre-commit + CI) -->
68
+
69
+ ```bash
70
+ npm install @aihu/reactive
71
+ # or
72
+ bun add @aihu/reactive
73
+ ```
74
+
75
+ <sub><i>Auto-generated against `@aihu/reactive@0.2.0`.</i></sub>
76
+
77
+ <!-- END_AUTOGEN: install -->
78
+
79
+ ## Package facts
80
+
81
+ <!-- BEGIN_AUTOGEN: stats -->
82
+ <!-- regenerate: bun scripts/sync-readme.ts (also runs in pre-commit + CI) -->
83
+
84
+ | | |
85
+ |---|---|
86
+ | **Version** | `0.2.0` |
87
+ | **Tier** | G — State — fine-grained Proxy-backed deep reactive trees (lazy per-key nodes, plain-assignment writes) |
88
+ | **Bundle size** | 1.28 kB (gz) — limit 1900 B |
89
+ | **Published files** | 3 entries |
90
+ | **License** | MIT |
91
+
92
+ <sub><i>Auto-generated against `@aihu/reactive@0.2.0`.</i></sub>
93
+
94
+ <!-- END_AUTOGEN: stats -->
95
+
96
+ ## Exports
97
+
98
+ <!-- BEGIN_AUTOGEN: exports -->
99
+ <!-- regenerate: bun scripts/sync-readme.ts (also runs in pre-commit + CI) -->
100
+
101
+ | Subpath | ESM | CJS |
102
+ |---|---|---|
103
+ | `.` | `./dist/index.js` | `—` |
104
+ | `./helpers` | `./dist/helpers.js` | `—` |
105
+
106
+ <sub><i>Auto-generated against `@aihu/reactive@0.2.0`.</i></sub>
107
+
108
+ <!-- END_AUTOGEN: exports -->
109
+
110
+ ## Dependencies
111
+
112
+ <!-- BEGIN_AUTOGEN: deps -->
113
+ <!-- regenerate: bun scripts/sync-readme.ts (also runs in pre-commit + CI) -->
114
+
115
+ **Dependencies:**
116
+
117
+ - `@aihu/signals` — `workspace:*`
118
+
119
+ <sub><i>Auto-generated against `@aihu/reactive@0.2.0`.</i></sub>
120
+
121
+ <!-- END_AUTOGEN: deps -->
122
+
123
+ ## See also
124
+
125
+ <!-- BEGIN_AUTOGEN: see-also -->
126
+ <!-- regenerate: bun scripts/sync-readme.ts (also runs in pre-commit + CI) -->
127
+
128
+ - [@aihu/signals](../signals)
129
+ - [@aihu/store](../store)
130
+ - [Aihu framework root](../../README.md)
131
+
132
+ <sub><i>Auto-generated against `@aihu/reactive@0.2.0`.</i></sub>
133
+
134
+ <!-- END_AUTOGEN: see-also -->
135
+
136
+ ## License
137
+
138
+ <!-- BEGIN_AUTOGEN: license -->
139
+ <!-- regenerate: bun scripts/sync-readme.ts (also runs in pre-commit + CI) -->
140
+
141
+ MIT — see [LICENSE](../../LICENSE).
142
+
143
+ <sub><i>Auto-generated against `@aihu/reactive@0.2.0`.</i></sub>
144
+
145
+ <!-- END_AUTOGEN: license -->
@@ -0,0 +1,29 @@
1
+ import { Signal } from "@aihu/signals";
2
+
3
+ //#region src/helpers/index.d.ts
4
+ /** Lens a single property as a signal tuple — the tree → tuple bridge.
5
+ * Reads track the SAME per-key node the proxy's `get` trap would create
6
+ * (it IS that trap); writes go through the proxy's `set` trap. */
7
+ declare function toSignal<T extends object, K extends keyof T>(t: T, k: K): Signal<T[K]>;
8
+ /** Every own key as a signal tuple. Destructure-safe (each tuple is a live
9
+ * lens). */
10
+ declare function toSignals<T extends object>(t: T): { [K in keyof T]: Signal<T[K]> };
11
+ /** Signal-of-object → reactive-looking view. Whole-value read granularity;
12
+ * writes go through the tuple's setter with a shallow copy. The tuple →
13
+ * tree bridge. */
14
+ declare function toReactive<T extends object>(source: Signal<T>): T;
15
+ /** Read-through view over a subset of keys — no copies, tracking is
16
+ * preserved (each read forwards to the source proxy's own trap). */
17
+ declare function reactivePick<T extends object, K extends keyof T>(s: T, ...keys: K[]): Pick<T, K>;
18
+ /** Read-through view omitting a subset of keys — no copies, tracking is
19
+ * preserved. */
20
+ declare function reactiveOmit<T extends object, K extends keyof T>(s: T, ...keys: K[]): Omit<T, K>;
21
+ /** A reactive object kept in sync with `fn()` by an effect + `reconcile`.
22
+ * Scope-owned: `effect()` registers with the current scope like any other
23
+ * effect, so the enclosing `effectScope` disposes it. Per-key granularity
24
+ * — consumers that read one key only re-run when THAT key changes,
25
+ * because `reconcile` notifies only the keys that actually changed. */
26
+ declare function reactiveComputed<T extends object>(fn: () => T): T;
27
+ //#endregion
28
+ export { reactiveComputed, reactiveOmit, reactivePick, toReactive, toSignal, toSignals };
29
+ //# sourceMappingURL=helpers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"helpers.d.ts","names":[],"sources":["../src/helpers/index.ts"],"mappings":";;;;;AAgBA;iBAAgB,QAAA,mCAA2C,CAAA,CAAA,CAAG,CAAA,EAAG,CAAA,EAAG,CAAA,EAAG,CAAA,GAAI,MAAA,CAAO,CAAA,CAAE,CAAA;;;iBAUpE,SAAA,kBAAA,CAA4B,CAAA,EAAG,CAAA,iBAAkB,CAAA,GAAI,MAAA,CAAO,CAAA,CAAE,CAAA;;;;iBAW9D,UAAA,kBAAA,CAA6B,MAAA,EAAQ,MAAA,CAAO,CAAA,IAAK,CAAA;;;iBAkCjD,YAAA,mCAA+C,CAAA,CAAA,CAAG,CAAA,EAAG,CAAA,KAAM,IAAA,EAAM,CAAA,KAAM,IAAA,CAAK,CAAA,EAAG,CAAA;;;iBAyB/E,YAAA,mCAA+C,CAAA,CAAA,CAAG,CAAA,EAAG,CAAA,KAAM,IAAA,EAAM,CAAA,KAAM,IAAA,CAAK,CAAA,EAAG,CAAA;;;;;;iBA4B/E,gBAAA,kBAAA,CAAmC,EAAA,QAAU,CAAA,GAAI,CAAA"}
@@ -0,0 +1,135 @@
1
+ import { effect } from "@aihu/signals";
2
+ import { reactive, reconcile } from "@aihu/reactive";
3
+ //#region src/helpers/index.ts
4
+ /**
5
+ * `@aihu/reactive/helpers` — the tree ↔ tuple bridge (design
6
+ * docs/plans/2026-07-24-deep-reactivity.md §4.2).
7
+ *
8
+ * Imports the core (`@aihu/reactive`) by package NAME rather than a
9
+ * relative path, and that self-import is marked `external` in
10
+ * rolldown.config.ts (mirroring `@aihu/signals` being external to the
11
+ * core row) — so this entry measures only the helper-specific code, never
12
+ * a duplicate copy of the trap machinery.
13
+ */
14
+ /** Lens a single property as a signal tuple — the tree → tuple bridge.
15
+ * Reads track the SAME per-key node the proxy's `get` trap would create
16
+ * (it IS that trap); writes go through the proxy's `set` trap. */
17
+ function toSignal(t, k) {
18
+ const read = () => t[k];
19
+ const write = (next) => {
20
+ t[k] = typeof next === "function" ? next(t[k]) : next;
21
+ };
22
+ return [read, write];
23
+ }
24
+ /** Every own key as a signal tuple. Destructure-safe (each tuple is a live
25
+ * lens). */
26
+ function toSignals(t) {
27
+ const out = {};
28
+ for (const k of Object.keys(t)) out[k] = toSignal(t, k);
29
+ return out;
30
+ }
31
+ /** Signal-of-object → reactive-looking view. Whole-value read granularity;
32
+ * writes go through the tuple's setter with a shallow copy. The tuple →
33
+ * tree bridge. */
34
+ function toReactive(source) {
35
+ const [read, write] = source;
36
+ return new Proxy({}, {
37
+ get(_t, key) {
38
+ return read()[key];
39
+ },
40
+ set(_t, key, value) {
41
+ write((prev) => ({
42
+ ...prev,
43
+ [key]: value
44
+ }));
45
+ return true;
46
+ },
47
+ has(_t, key) {
48
+ return key in read();
49
+ },
50
+ deleteProperty(_t, key) {
51
+ write((prev) => {
52
+ const next = { ...prev };
53
+ delete next[key];
54
+ return next;
55
+ });
56
+ return true;
57
+ },
58
+ ownKeys() {
59
+ return Reflect.ownKeys(read());
60
+ },
61
+ getOwnPropertyDescriptor(_t, key) {
62
+ const obj = read();
63
+ if (!(key in obj)) return void 0;
64
+ return {
65
+ enumerable: true,
66
+ configurable: true,
67
+ value: obj[key]
68
+ };
69
+ }
70
+ });
71
+ }
72
+ /** Read-through view over a subset of keys — no copies, tracking is
73
+ * preserved (each read forwards to the source proxy's own trap). */
74
+ function reactivePick(s, ...keys) {
75
+ const keySet = new Set(keys);
76
+ return new Proxy({}, {
77
+ get(_t, key) {
78
+ return keySet.has(key) ? s[key] : void 0;
79
+ },
80
+ has(_t, key) {
81
+ return keySet.has(key) && key in s;
82
+ },
83
+ ownKeys() {
84
+ return [...keySet];
85
+ },
86
+ getOwnPropertyDescriptor(_t, key) {
87
+ if (!keySet.has(key)) return void 0;
88
+ return {
89
+ enumerable: true,
90
+ configurable: true,
91
+ value: s[key]
92
+ };
93
+ }
94
+ });
95
+ }
96
+ /** Read-through view omitting a subset of keys — no copies, tracking is
97
+ * preserved. */
98
+ function reactiveOmit(s, ...keys) {
99
+ const omitSet = new Set(keys);
100
+ return new Proxy({}, {
101
+ get(_t, key) {
102
+ return omitSet.has(key) ? void 0 : s[key];
103
+ },
104
+ has(_t, key) {
105
+ return !omitSet.has(key) && key in s;
106
+ },
107
+ ownKeys() {
108
+ return Reflect.ownKeys(s).filter((k) => !omitSet.has(k));
109
+ },
110
+ getOwnPropertyDescriptor(_t, key) {
111
+ if (omitSet.has(key)) return void 0;
112
+ return {
113
+ enumerable: true,
114
+ configurable: true,
115
+ value: s[key]
116
+ };
117
+ }
118
+ });
119
+ }
120
+ /** A reactive object kept in sync with `fn()` by an effect + `reconcile`.
121
+ * Scope-owned: `effect()` registers with the current scope like any other
122
+ * effect, so the enclosing `effectScope` disposes it. Per-key granularity
123
+ * — consumers that read one key only re-run when THAT key changes,
124
+ * because `reconcile` notifies only the keys that actually changed. */
125
+ function reactiveComputed(fn) {
126
+ const target = reactive({});
127
+ effect(() => {
128
+ reconcile(target, fn());
129
+ });
130
+ return target;
131
+ }
132
+ //#endregion
133
+ export { reactiveComputed, reactiveOmit, reactivePick, toReactive, toSignal, toSignals };
134
+
135
+ //# sourceMappingURL=helpers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"helpers.js","names":[],"sources":["../src/helpers/index.ts"],"sourcesContent":["/**\n * `@aihu/reactive/helpers` — the tree ↔ tuple bridge (design\n * docs/plans/2026-07-24-deep-reactivity.md §4.2).\n *\n * Imports the core (`@aihu/reactive`) by package NAME rather than a\n * relative path, and that self-import is marked `external` in\n * rolldown.config.ts (mirroring `@aihu/signals` being external to the\n * core row) — so this entry measures only the helper-specific code, never\n * a duplicate copy of the trap machinery.\n */\nimport { reactive, reconcile } from '@aihu/reactive'\nimport { effect, type Signal } from '@aihu/signals'\n\n/** Lens a single property as a signal tuple — the tree → tuple bridge.\n * Reads track the SAME per-key node the proxy's `get` trap would create\n * (it IS that trap); writes go through the proxy's `set` trap. */\nexport function toSignal<T extends object, K extends keyof T>(t: T, k: K): Signal<T[K]> {\n const read = () => t[k]\n const write = (next: unknown) => {\n t[k] = typeof next === 'function' ? (next as (prev: T[K]) => T[K])(t[k]) : (next as T[K])\n }\n return [read, write] as unknown as Signal<T[K]>\n}\n\n/** Every own key as a signal tuple. Destructure-safe (each tuple is a live\n * lens). */\nexport function toSignals<T extends object>(t: T): { [K in keyof T]: Signal<T[K]> } {\n const out = {} as { [K in keyof T]: Signal<T[K]> }\n for (const k of Object.keys(t) as Array<keyof T>) {\n out[k] = toSignal(t, k)\n }\n return out\n}\n\n/** Signal-of-object → reactive-looking view. Whole-value read granularity;\n * writes go through the tuple's setter with a shallow copy. The tuple →\n * tree bridge. */\nexport function toReactive<T extends object>(source: Signal<T>): T {\n const [read, write] = source\n return new Proxy({} as T, {\n get(_t, key) {\n return (read() as Record<PropertyKey, unknown>)[key]\n },\n set(_t, key, value) {\n write((prev) => ({ ...(prev as object), [key]: value }) as T)\n return true\n },\n has(_t, key) {\n return key in (read() as object)\n },\n deleteProperty(_t, key) {\n write((prev) => {\n const next = { ...(prev as object) } as Record<PropertyKey, unknown>\n delete next[key]\n return next as T\n })\n return true\n },\n ownKeys() {\n return Reflect.ownKeys(read() as object)\n },\n getOwnPropertyDescriptor(_t, key) {\n const obj = read() as Record<PropertyKey, unknown>\n if (!(key in obj)) return undefined\n return { enumerable: true, configurable: true, value: obj[key] }\n },\n }) as T\n}\n\n/** Read-through view over a subset of keys — no copies, tracking is\n * preserved (each read forwards to the source proxy's own trap). */\nexport function reactivePick<T extends object, K extends keyof T>(s: T, ...keys: K[]): Pick<T, K> {\n const keySet = new Set<PropertyKey>(keys)\n return new Proxy({} as Pick<T, K>, {\n get(_t, key) {\n return keySet.has(key) ? (s as Record<PropertyKey, unknown>)[key] : undefined\n },\n has(_t, key) {\n return keySet.has(key) && key in (s as object)\n },\n ownKeys() {\n return [...keySet] as (string | symbol)[]\n },\n getOwnPropertyDescriptor(_t, key) {\n if (!keySet.has(key)) return undefined\n return {\n enumerable: true,\n configurable: true,\n value: (s as Record<PropertyKey, unknown>)[key],\n }\n },\n }) as Pick<T, K>\n}\n\n/** Read-through view omitting a subset of keys — no copies, tracking is\n * preserved. */\nexport function reactiveOmit<T extends object, K extends keyof T>(s: T, ...keys: K[]): Omit<T, K> {\n const omitSet = new Set<PropertyKey>(keys)\n return new Proxy({} as Omit<T, K>, {\n get(_t, key) {\n return omitSet.has(key) ? undefined : (s as Record<PropertyKey, unknown>)[key]\n },\n has(_t, key) {\n return !omitSet.has(key) && key in (s as object)\n },\n ownKeys() {\n return Reflect.ownKeys(s as object).filter((k) => !omitSet.has(k))\n },\n getOwnPropertyDescriptor(_t, key) {\n if (omitSet.has(key)) return undefined\n return {\n enumerable: true,\n configurable: true,\n value: (s as Record<PropertyKey, unknown>)[key],\n }\n },\n }) as Omit<T, K>\n}\n\n/** A reactive object kept in sync with `fn()` by an effect + `reconcile`.\n * Scope-owned: `effect()` registers with the current scope like any other\n * effect, so the enclosing `effectScope` disposes it. Per-key granularity\n * — consumers that read one key only re-run when THAT key changes,\n * because `reconcile` notifies only the keys that actually changed. */\nexport function reactiveComputed<T extends object>(fn: () => T): T {\n const target = reactive({} as T)\n effect(() => {\n const next = fn()\n reconcile(target, next)\n })\n return target\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAgBA,SAAgB,SAA8C,GAAM,GAAoB;CACtF,MAAM,aAAa,EAAE;CACrB,MAAM,SAAS,SAAkB;EAC/B,EAAE,KAAK,OAAO,SAAS,aAAc,KAA8B,EAAE,GAAG,GAAI;;CAE9E,OAAO,CAAC,MAAM,MAAM;;;;AAKtB,SAAgB,UAA4B,GAAwC;CAClF,MAAM,MAAM,EAAE;CACd,KAAK,MAAM,KAAK,OAAO,KAAK,EAAE,EAC5B,IAAI,KAAK,SAAS,GAAG,EAAE;CAEzB,OAAO;;;;;AAMT,SAAgB,WAA6B,QAAsB;CACjE,MAAM,CAAC,MAAM,SAAS;CACtB,OAAO,IAAI,MAAM,EAAE,EAAO;EACxB,IAAI,IAAI,KAAK;GACX,OAAQ,MAAM,CAAkC;;EAElD,IAAI,IAAI,KAAK,OAAO;GAClB,OAAO,UAAU;IAAE,GAAI;KAAkB,MAAM;IAAO,EAAO;GAC7D,OAAO;;EAET,IAAI,IAAI,KAAK;GACX,OAAO,OAAQ,MAAM;;EAEvB,eAAe,IAAI,KAAK;GACtB,OAAO,SAAS;IACd,MAAM,OAAO,EAAE,GAAI,MAAiB;IACpC,OAAO,KAAK;IACZ,OAAO;KACP;GACF,OAAO;;EAET,UAAU;GACR,OAAO,QAAQ,QAAQ,MAAM,CAAW;;EAE1C,yBAAyB,IAAI,KAAK;GAChC,MAAM,MAAM,MAAM;GAClB,IAAI,EAAE,OAAO,MAAM,OAAO,KAAA;GAC1B,OAAO;IAAE,YAAY;IAAM,cAAc;IAAM,OAAO,IAAI;IAAM;;EAEnE,CAAC;;;;AAKJ,SAAgB,aAAkD,GAAM,GAAG,MAAuB;CAChG,MAAM,SAAS,IAAI,IAAiB,KAAK;CACzC,OAAO,IAAI,MAAM,EAAE,EAAgB;EACjC,IAAI,IAAI,KAAK;GACX,OAAO,OAAO,IAAI,IAAI,GAAI,EAAmC,OAAO,KAAA;;EAEtE,IAAI,IAAI,KAAK;GACX,OAAO,OAAO,IAAI,IAAI,IAAI,OAAQ;;EAEpC,UAAU;GACR,OAAO,CAAC,GAAG,OAAO;;EAEpB,yBAAyB,IAAI,KAAK;GAChC,IAAI,CAAC,OAAO,IAAI,IAAI,EAAE,OAAO,KAAA;GAC7B,OAAO;IACL,YAAY;IACZ,cAAc;IACd,OAAQ,EAAmC;IAC5C;;EAEJ,CAAC;;;;AAKJ,SAAgB,aAAkD,GAAM,GAAG,MAAuB;CAChG,MAAM,UAAU,IAAI,IAAiB,KAAK;CAC1C,OAAO,IAAI,MAAM,EAAE,EAAgB;EACjC,IAAI,IAAI,KAAK;GACX,OAAO,QAAQ,IAAI,IAAI,GAAG,KAAA,IAAa,EAAmC;;EAE5E,IAAI,IAAI,KAAK;GACX,OAAO,CAAC,QAAQ,IAAI,IAAI,IAAI,OAAQ;;EAEtC,UAAU;GACR,OAAO,QAAQ,QAAQ,EAAY,CAAC,QAAQ,MAAM,CAAC,QAAQ,IAAI,EAAE,CAAC;;EAEpE,yBAAyB,IAAI,KAAK;GAChC,IAAI,QAAQ,IAAI,IAAI,EAAE,OAAO,KAAA;GAC7B,OAAO;IACL,YAAY;IACZ,cAAc;IACd,OAAQ,EAAmC;IAC5C;;EAEJ,CAAC;;;;;;;AAQJ,SAAgB,iBAAmC,IAAgB;CACjE,MAAM,SAAS,SAAS,EAAE,CAAM;CAChC,aAAa;EAEX,UAAU,QADG,IACS,CAAC;GACvB;CACF,OAAO"}
@@ -0,0 +1,33 @@
1
+ //#region src/internal.d.ts
2
+ /**
3
+ * Wrap a plain object/array in a fine-grained reactive tree. Idempotent and
4
+ * identity-stable: `reactive(o) === reactive(o)`, `reactive(reactive(o)) ===
5
+ * reactive(o)`. Non-wrappable values (Date, Map, Set, class instances,
6
+ * frozen objects, primitives) are returned as-is.
7
+ */
8
+ declare function reactive<T extends object>(source: T): T;
9
+ /** True for a proxy produced by `reactive()`. */
10
+ declare function isReactive(value: unknown): boolean;
11
+ /** The raw object behind a reactive proxy (O(1), no traversal — writes
12
+ * unwrap, so the raw tree never contains proxies). Non-proxies pass
13
+ * through unchanged. */
14
+ declare function unwrap<T>(value: T): T;
15
+ /** Apply many writes as ONE flush. Equivalent to `batch(() => recipe(target))`
16
+ * — the "draft" IS the reactive proxy; writes apply immediately (design
17
+ * §8.6: NOT an Immer draft — a throwing recipe leaves partial writes, same
18
+ * non-atomic-on-error posture `batch()` already documents). */
19
+ declare function mutate<T extends object>(target: T, recipe: (draft: T) => void): void;
20
+ type ReconcileOptions = {
21
+ key?: PropertyKey | ((item: unknown) => unknown);
22
+ };
23
+ /**
24
+ * Merge `next` into `target` in place, preserving node identity for
25
+ * unchanged values and notifying ONLY changed paths. The hydration /
26
+ * refetch primitive. `key` controls array item matching (default: index),
27
+ * applied recursively to every array reconcile encounters, not only a
28
+ * top-level one.
29
+ */
30
+ declare function reconcile<T extends object>(target: T, next: T, options?: ReconcileOptions): void;
31
+ //#endregion
32
+ export { isReactive, mutate, reactive, reconcile, unwrap };
33
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/internal.ts"],"mappings":";;AA8PA;;;;;iBAAgB,QAAA,kBAAA,CAA2B,MAAA,EAAQ,CAAA,GAAI,CAAA;;iBAavC,UAAA,CAAW,KAAA;;;AAA3B;iBAOgB,MAAA,GAAA,CAAU,KAAA,EAAO,CAAA,GAAI,CAAA;;;;AAArC;iBAYgB,MAAA,kBAAA,CAAyB,MAAA,EAAQ,CAAA,EAAG,MAAA,GAAS,KAAA,EAAO,CAAA;AAAA,KAM/D,gBAAA;EAAqB,GAAA,GAAM,WAAA,KAAgB,IAAA;AAAA;;;;;;AANhD;;iBA2GgB,SAAA,kBAAA,CAA4B,MAAA,EAAQ,CAAA,EAAG,IAAA,EAAM,CAAA,EAAG,OAAA,GAAU,gBAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,285 @@
1
+ import { batch, signal } from "@aihu/signals";
2
+ //#region src/internal.ts
3
+ /**
4
+ * `@aihu/reactive` — fine-grained Proxy-backed deep reactive trees on
5
+ * `@aihu/signals` (docs/plans/2026-07-24-deep-reactivity.md).
6
+ *
7
+ * Mechanism (design §2.6/§2.7): a Solid-shaped node model — lazily allocated
8
+ * per-(object, key) tracking nodes, one `signal(0, { equals: false })` per
9
+ * touched key used as a pure version token (the VALUE lives on the raw
10
+ * object; the node only carries subscription edges) — with Vue-shaped
11
+ * write ergonomics: plain `obj.key = value` assignment through the `set`
12
+ * trap, no `setStore(...)` path tuples.
13
+ *
14
+ * `@aihu/signals` is the sole dependency and is marked `external` in
15
+ * rolldown.config.ts — this package adds zero bytes to the signals core row.
16
+ */
17
+ /** raw → proxy. Stable so `reactive(o) === reactive(o)`. */
18
+ const wrapMap = /* @__PURE__ */ new WeakMap();
19
+ /** proxy → raw. `unwrap()` is a single lookup here — O(1), no traversal. */
20
+ const rawMap = /* @__PURE__ */ new WeakMap();
21
+ /** raw → per-key tracking node map. Lazily populated (design §2.7): a node
22
+ * is allocated on the FIRST get-trap touch of a key, tracked or not. */
23
+ const nodeMap = /* @__PURE__ */ new WeakMap();
24
+ /** @internal — sentinel key for the per-object "shape" node: notified on
25
+ * property add/delete, tracked by `ownKeys`/`has` (design §2.6). Never
26
+ * collides with a real property key (own module-local symbol). */
27
+ const KEYS = Symbol("aihu-reactive-keys");
28
+ /** Array mutator methods that touch more than one index/length slot — run
29
+ * inside `batch()` so e.g. `arr.push(a, b)` is one flush, not N (design
30
+ * §2.6: "array mutating methods run inside batch()"). Intercepted directly
31
+ * in the `get` trap rather than tracked as a plain property read: nobody
32
+ * meaningfully subscribes to the identity of the `push` function itself. */
33
+ const ARRAY_MUTATORS = new Set([
34
+ "push",
35
+ "pop",
36
+ "shift",
37
+ "unshift",
38
+ "splice",
39
+ "sort",
40
+ "reverse",
41
+ "fill",
42
+ "copyWithin"
43
+ ]);
44
+ function getNodes(raw) {
45
+ let nodes = nodeMap.get(raw);
46
+ if (nodes === void 0) {
47
+ nodes = /* @__PURE__ */ new Map();
48
+ nodeMap.set(raw, nodes);
49
+ }
50
+ return nodes;
51
+ }
52
+ function getOrCreateNode(nodes, key) {
53
+ let node = nodes.get(key);
54
+ if (node === void 0) {
55
+ node = signal(0, { equals: false });
56
+ nodes.set(key, node);
57
+ }
58
+ return node;
59
+ }
60
+ function isArrayIndexKey(key) {
61
+ if (typeof key !== "string") return false;
62
+ if (key === "") return false;
63
+ const n = Number(key);
64
+ return Number.isInteger(n) && n >= 0 && String(n) === key;
65
+ }
66
+ /** Solid's `isWrappable`, minus the collection-type carve-outs this design
67
+ * doesn't need (design §2.2, §2.6): plain objects and arrays, not frozen. */
68
+ function isWrappable(v) {
69
+ if (Object.isFrozen(v)) return false;
70
+ if (Array.isArray(v)) return true;
71
+ const proto = Object.getPrototypeOf(v);
72
+ return proto === Object.prototype || proto === null;
73
+ }
74
+ function isPlainObjectLike(v) {
75
+ if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
76
+ const proto = Object.getPrototypeOf(v);
77
+ return proto === Object.prototype || proto === null;
78
+ }
79
+ function sameContainerShape(a, b) {
80
+ if (Array.isArray(a) && Array.isArray(b)) return true;
81
+ return isPlainObjectLike(a) && isPlainObjectLike(b);
82
+ }
83
+ /** Recursively replace nested reactive proxies found inside a freshly
84
+ * assigned plain container with their raw counterparts, IN PLACE, before
85
+ * that container is stored on the raw tree (design §2.6/§8.4: "the raw
86
+ * tree never contains proxies"). Plain `unwrap()` is a single WeakMap
87
+ * lookup and only strips a DIRECTLY-assigned proxy — `outer.box = { inner:
88
+ * someProxy }` would otherwise smuggle `someProxy` in under `box.inner`
89
+ * since `box` itself was never a proxy. Walks only wrappable containers
90
+ * (same class `isWrappable` recognizes); a `seen` WeakSet guards against
91
+ * cyclic user data. */
92
+ function unwrapDeep(value, seen) {
93
+ if (value === null || typeof value !== "object") return value;
94
+ const raw = rawMap.get(value);
95
+ const container = raw !== void 0 ? raw : value;
96
+ if (!isWrappable(container)) return container;
97
+ const visited = seen ?? /* @__PURE__ */ new WeakSet();
98
+ if (visited.has(container)) return container;
99
+ visited.add(container);
100
+ const rec = container;
101
+ for (const k of Reflect.ownKeys(rec)) {
102
+ const v = rec[k];
103
+ if (v !== null && typeof v === "object") rec[k] = unwrapDeep(v, visited);
104
+ }
105
+ return container;
106
+ }
107
+ const handlers = {
108
+ get(target, key, receiver) {
109
+ if (Array.isArray(target) && ARRAY_MUTATORS.has(key)) {
110
+ const fn = target[key];
111
+ return (...args) => batch(() => fn.apply(receiver, args));
112
+ }
113
+ getOrCreateNode(getNodes(target), key)[0]();
114
+ const res = target[key];
115
+ return res !== null && typeof res === "object" ? reactive(res) : res;
116
+ },
117
+ set(target, key, value) {
118
+ const isArray = Array.isArray(target);
119
+ const rawValue = unwrapDeep(value);
120
+ const record = target;
121
+ const hadKey = key in target;
122
+ const oldValue = record[key];
123
+ if (hadKey && Object.is(rawValue, oldValue)) return true;
124
+ const nodes = getNodes(target);
125
+ if (isArray && key === "length") {
126
+ const oldLen = oldValue;
127
+ record.length = rawValue;
128
+ const newLen = record.length;
129
+ const lengthNode = getOrCreateNode(nodes, "length");
130
+ if (newLen < oldLen) batch(() => {
131
+ lengthNode[1]((v) => v + 1);
132
+ for (let i = newLen; i < oldLen; i++) {
133
+ const idxNode = nodes.get(String(i));
134
+ if (idxNode !== void 0) idxNode[1]((v) => v + 1);
135
+ }
136
+ });
137
+ else lengthNode[1]((v) => v + 1);
138
+ return true;
139
+ }
140
+ record[key] = rawValue;
141
+ const keyNode = getOrCreateNode(nodes, key);
142
+ if (hadKey) {
143
+ keyNode[1]((v) => v + 1);
144
+ return true;
145
+ }
146
+ const companion = getOrCreateNode(nodes, isArray && isArrayIndexKey(key) ? "length" : KEYS);
147
+ batch(() => {
148
+ keyNode[1]((v) => v + 1);
149
+ companion[1]((v) => v + 1);
150
+ });
151
+ return true;
152
+ },
153
+ has(target, key) {
154
+ const nodes = getNodes(target);
155
+ getOrCreateNode(nodes, KEYS)[0]();
156
+ if (Array.isArray(target)) getOrCreateNode(nodes, "length")[0]();
157
+ return key in target;
158
+ },
159
+ deleteProperty(target, key) {
160
+ const had = key in target;
161
+ const ok = delete target[key];
162
+ if (had && ok) {
163
+ const nodes = getNodes(target);
164
+ const keyNode = nodes.get(key);
165
+ const keysNode = getOrCreateNode(nodes, KEYS);
166
+ batch(() => {
167
+ if (keyNode !== void 0) keyNode[1]((v) => v + 1);
168
+ keysNode[1]((v) => v + 1);
169
+ });
170
+ }
171
+ return ok;
172
+ },
173
+ ownKeys(target) {
174
+ const nodes = getNodes(target);
175
+ getOrCreateNode(nodes, KEYS)[0]();
176
+ if (Array.isArray(target)) getOrCreateNode(nodes, "length")[0]();
177
+ return Reflect.ownKeys(target);
178
+ }
179
+ };
180
+ /**
181
+ * Wrap a plain object/array in a fine-grained reactive tree. Idempotent and
182
+ * identity-stable: `reactive(o) === reactive(o)`, `reactive(reactive(o)) ===
183
+ * reactive(o)`. Non-wrappable values (Date, Map, Set, class instances,
184
+ * frozen objects, primitives) are returned as-is.
185
+ */
186
+ function reactive(source) {
187
+ if (source === null || typeof source !== "object") return source;
188
+ if (rawMap.has(source)) return source;
189
+ const cached = wrapMap.get(source);
190
+ if (cached !== void 0) return cached;
191
+ if (!isWrappable(source)) return source;
192
+ const proxy = new Proxy(source, handlers);
193
+ wrapMap.set(source, proxy);
194
+ rawMap.set(proxy, source);
195
+ return proxy;
196
+ }
197
+ /** True for a proxy produced by `reactive()`. */
198
+ function isReactive(value) {
199
+ return value !== null && typeof value === "object" && rawMap.has(value);
200
+ }
201
+ /** The raw object behind a reactive proxy (O(1), no traversal — writes
202
+ * unwrap, so the raw tree never contains proxies). Non-proxies pass
203
+ * through unchanged. */
204
+ function unwrap(value) {
205
+ if (value !== null && typeof value === "object") {
206
+ const raw = rawMap.get(value);
207
+ if (raw !== void 0) return raw;
208
+ }
209
+ return value;
210
+ }
211
+ /** Apply many writes as ONE flush. Equivalent to `batch(() => recipe(target))`
212
+ * — the "draft" IS the reactive proxy; writes apply immediately (design
213
+ * §8.6: NOT an Immer draft — a throwing recipe leaves partial writes, same
214
+ * non-atomic-on-error posture `batch()` already documents). */
215
+ function mutate(target, recipe) {
216
+ batch(() => recipe(target));
217
+ }
218
+ function reconcileInto(proxy, next, options) {
219
+ const raw = unwrap(proxy);
220
+ if (Array.isArray(raw) && Array.isArray(next)) {
221
+ if (options?.key !== void 0) {
222
+ reconcileArrayKeyed(proxy, raw, next, options);
223
+ return;
224
+ }
225
+ for (let i = 0; i < next.length; i++) reconcileField(proxy, i, raw[i], next[i], i < raw.length, options);
226
+ if (next.length < raw.length) proxy.length = next.length;
227
+ return;
228
+ }
229
+ if (isPlainObjectLike(raw) && isPlainObjectLike(next)) {
230
+ for (const k of Reflect.ownKeys(next)) reconcileField(proxy, k, raw[k], next[k], Object.hasOwn(raw, k), options);
231
+ for (const k of Reflect.ownKeys(raw)) if (!Object.hasOwn(next, k)) delete proxy[k];
232
+ }
233
+ }
234
+ /** Reconcile one field. Reads for comparison come from the RAW values
235
+ * (never through the proxy) so this can run safely inside the tracking
236
+ * effect that drives `reactiveComputed` without the write-side of this
237
+ * same reconcile becoming a read-side dependency of itself. Only the
238
+ * write path (`proxy[key] = …`) touches the proxy.
239
+ *
240
+ * `hadKey` mirrors the `set` trap's own add-detection fix: `curVal` for a
241
+ * key genuinely absent from `raw` reads as `undefined`, same as an
242
+ * explicit `undefined` value would — `Object.is` alone can't tell "already
243
+ * undefined" from "never existed" apart, so a payload adding an
244
+ * explicitly-`undefined`-valued key would otherwise be silently dropped as
245
+ * a no-op. */
246
+ function reconcileField(proxy, key, curVal, nextVal, hadKey, options) {
247
+ if (sameContainerShape(curVal, nextVal)) reconcileInto(reactive(curVal), nextVal, options);
248
+ else if (!hadKey || !Object.is(curVal, nextVal)) proxy[key] = nextVal;
249
+ }
250
+ function reconcileArrayKeyed(proxy, raw, next, options) {
251
+ const keyOpt = options.key;
252
+ const idOf = typeof keyOpt === "function" ? keyOpt : (item) => item?.[keyOpt];
253
+ const byKey = /* @__PURE__ */ new Map();
254
+ for (const item of raw) byKey.set(idOf(item), item);
255
+ const merged = new Array(next.length);
256
+ for (let i = 0; i < next.length; i++) {
257
+ const nextItem = next[i];
258
+ const id = idOf(nextItem);
259
+ const match = byKey.get(id);
260
+ if (match !== void 0 && sameContainerShape(match, nextItem)) {
261
+ reconcileInto(reactive(match), nextItem, options);
262
+ merged[i] = match;
263
+ byKey.delete(id);
264
+ } else merged[i] = nextItem;
265
+ }
266
+ for (let i = 0; i < merged.length; i++) if (!Object.is(raw[i], merged[i])) proxy[i] = merged[i];
267
+ if (merged.length < raw.length) proxy.length = merged.length;
268
+ }
269
+ /**
270
+ * Merge `next` into `target` in place, preserving node identity for
271
+ * unchanged values and notifying ONLY changed paths. The hydration /
272
+ * refetch primitive. `key` controls array item matching (default: index),
273
+ * applied recursively to every array reconcile encounters, not only a
274
+ * top-level one.
275
+ */
276
+ function reconcile(target, next, options) {
277
+ const proxy = reactive(target);
278
+ batch(() => {
279
+ reconcileInto(proxy, next, options);
280
+ });
281
+ }
282
+ //#endregion
283
+ export { isReactive, mutate, reactive, reconcile, unwrap };
284
+
285
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/internal.ts"],"sourcesContent":["/**\n * `@aihu/reactive` — fine-grained Proxy-backed deep reactive trees on\n * `@aihu/signals` (docs/plans/2026-07-24-deep-reactivity.md).\n *\n * Mechanism (design §2.6/§2.7): a Solid-shaped node model — lazily allocated\n * per-(object, key) tracking nodes, one `signal(0, { equals: false })` per\n * touched key used as a pure version token (the VALUE lives on the raw\n * object; the node only carries subscription edges) — with Vue-shaped\n * write ergonomics: plain `obj.key = value` assignment through the `set`\n * trap, no `setStore(...)` path tuples.\n *\n * `@aihu/signals` is the sole dependency and is marked `external` in\n * rolldown.config.ts — this package adds zero bytes to the signals core row.\n */\nimport { batch, type Signal, signal } from '@aihu/signals'\n\n// ───────── Identity maps (design §2.6) ─────────\n\n/** raw → proxy. Stable so `reactive(o) === reactive(o)`. */\nconst wrapMap = new WeakMap<object, object>()\n/** proxy → raw. `unwrap()` is a single lookup here — O(1), no traversal. */\nconst rawMap = new WeakMap<object, object>()\n/** raw → per-key tracking node map. Lazily populated (design §2.7): a node\n * is allocated on the FIRST get-trap touch of a key, tracked or not. */\nconst nodeMap = new WeakMap<object, Map<PropertyKey, Signal<number>>>()\n\n/** @internal — sentinel key for the per-object \"shape\" node: notified on\n * property add/delete, tracked by `ownKeys`/`has` (design §2.6). Never\n * collides with a real property key (own module-local symbol). */\nconst KEYS: unique symbol = Symbol('aihu-reactive-keys')\n\n/** Array mutator methods that touch more than one index/length slot — run\n * inside `batch()` so e.g. `arr.push(a, b)` is one flush, not N (design\n * §2.6: \"array mutating methods run inside batch()\"). Intercepted directly\n * in the `get` trap rather than tracked as a plain property read: nobody\n * meaningfully subscribes to the identity of the `push` function itself. */\nconst ARRAY_MUTATORS = new Set<PropertyKey>([\n 'push',\n 'pop',\n 'shift',\n 'unshift',\n 'splice',\n 'sort',\n 'reverse',\n 'fill',\n 'copyWithin',\n])\n\nfunction getNodes(raw: object): Map<PropertyKey, Signal<number>> {\n let nodes = nodeMap.get(raw)\n if (nodes === undefined) {\n nodes = new Map()\n nodeMap.set(raw, nodes)\n }\n return nodes\n}\n\nfunction getOrCreateNode(\n nodes: Map<PropertyKey, Signal<number>>,\n key: PropertyKey,\n): Signal<number> {\n let node = nodes.get(key)\n if (node === undefined) {\n node = signal(0, { equals: false })\n nodes.set(key, node)\n }\n return node\n}\n\nfunction isArrayIndexKey(key: PropertyKey): boolean {\n if (typeof key !== 'string') return false\n if (key === '') return false\n const n = Number(key)\n return Number.isInteger(n) && n >= 0 && String(n) === key\n}\n\n/** Solid's `isWrappable`, minus the collection-type carve-outs this design\n * doesn't need (design §2.2, §2.6): plain objects and arrays, not frozen. */\nfunction isWrappable(v: object): boolean {\n if (Object.isFrozen(v)) return false\n if (Array.isArray(v)) return true\n const proto = Object.getPrototypeOf(v)\n return proto === Object.prototype || proto === null\n}\n\nfunction isPlainObjectLike(v: unknown): v is Record<PropertyKey, unknown> {\n if (v === null || typeof v !== 'object' || Array.isArray(v)) return false\n const proto = Object.getPrototypeOf(v)\n return proto === Object.prototype || proto === null\n}\n\nfunction sameContainerShape(a: unknown, b: unknown): boolean {\n if (Array.isArray(a) && Array.isArray(b)) return true\n return isPlainObjectLike(a) && isPlainObjectLike(b)\n}\n\n/** Recursively replace nested reactive proxies found inside a freshly\n * assigned plain container with their raw counterparts, IN PLACE, before\n * that container is stored on the raw tree (design §2.6/§8.4: \"the raw\n * tree never contains proxies\"). Plain `unwrap()` is a single WeakMap\n * lookup and only strips a DIRECTLY-assigned proxy — `outer.box = { inner:\n * someProxy }` would otherwise smuggle `someProxy` in under `box.inner`\n * since `box` itself was never a proxy. Walks only wrappable containers\n * (same class `isWrappable` recognizes); a `seen` WeakSet guards against\n * cyclic user data. */\nfunction unwrapDeep(value: unknown, seen?: WeakSet<object>): unknown {\n if (value === null || typeof value !== 'object') return value\n const raw = rawMap.get(value as object)\n const container = raw !== undefined ? raw : (value as object)\n if (!isWrappable(container)) return container\n const visited = seen ?? new WeakSet<object>()\n if (visited.has(container)) return container\n visited.add(container)\n const rec = container as Record<PropertyKey, unknown>\n for (const k of Reflect.ownKeys(rec)) {\n const v = rec[k]\n if (v !== null && typeof v === 'object') {\n rec[k] = unwrapDeep(v, visited)\n }\n }\n return container\n}\n\n// ───────── Proxy traps (design §2.6) ─────────\n\nconst handlers: ProxyHandler<object> = {\n get(target, key, receiver) {\n if (Array.isArray(target) && ARRAY_MUTATORS.has(key)) {\n const fn = (target as unknown as Record<PropertyKey, (...a: unknown[]) => unknown>)[key] as (\n ...a: unknown[]\n ) => unknown\n // Apply with `this = receiver` (the proxy, not the raw target) so the\n // method's own internal index/length writes route through OUR `set`\n // trap — that's what makes `batch()` here collapse them into one\n // flush instead of bypassing tracking entirely.\n return (...args: unknown[]) => batch(() => fn.apply(receiver, args))\n }\n // First-touch allocation, tracked or not (design §2.7): the read() call\n // below is the ordinary signal reader — `if (currentObserver !== null)\n // linkAdd(...)` — so an untracked read allocates the node but no edge.\n getOrCreateNode(getNodes(target), key)[0]()\n const res = (target as Record<PropertyKey, unknown>)[key]\n return res !== null && typeof res === 'object' ? reactive(res as object) : res\n },\n\n set(target, key, value) {\n const isArray = Array.isArray(target)\n const rawValue = unwrapDeep(value)\n const record = target as Record<PropertyKey, unknown>\n // Add-detection MUST run before the equality short-circuit below:\n // assigning `undefined` to a key that does not yet exist has to still\n // create it (plain-JS parity, design §2.1/§2.6) — `oldValue` for a\n // missing key also reads as `undefined`, so checking `hadKey` first is\n // what tells the two cases apart.\n const hadKey = key in target\n const oldValue = record[key]\n // Equality short-circuit (design §2.6) — same Object.is rule and the\n // same \"no allocation, no notify\" shape signal.ts's write() already\n // applies to every tuple write. `obj.x = obj.x` is a correct no-op.\n if (hadKey && Object.is(rawValue, oldValue)) return true\n\n const nodes = getNodes(target)\n\n // Array length assignment is the one write that can silently drop (or\n // reintroduce) index properties without ever routing through\n // `deleteProperty` — handle it explicitly so effects subscribed to a\n // dropped index are notified. This is the exact path `reconcile()`'s\n // truncation uses (`proxy.length = next.length`), so fixing it here\n // fixes reconcile too (design §8.10's index-tracking model).\n if (isArray && key === 'length') {\n const oldLen = oldValue as number\n record.length = rawValue as number\n const newLen = record.length as number\n const lengthNode = getOrCreateNode(nodes, 'length')\n if (newLen < oldLen) {\n batch(() => {\n lengthNode[1]((v) => v + 1)\n for (let i = newLen; i < oldLen; i++) {\n const idxNode = nodes.get(String(i))\n if (idxNode !== undefined) idxNode[1]((v) => v + 1)\n }\n })\n } else {\n lengthNode[1]((v) => v + 1)\n }\n return true\n }\n\n record[key] = rawValue\n const keyNode = getOrCreateNode(nodes, key)\n if (hadKey) {\n // Plain value write on an existing key touches exactly one node —\n // no batch needed (matches tuple-write semantics: one write, one\n // flush).\n keyNode[1]((v) => v + 1)\n return true\n }\n // Add path: property add, or an array index write past the current\n // length. Both touch the key's own node AND a shape companion node\n // (KEYS for objects / length for array-index adds) — batched together\n // so the trap never produces two back-to-back synchronous drains for\n // one authored assignment (design §2.6). `ownKeys`/`has` track BOTH\n // nodes for arrays (below), so bumping `length` alone still wakes\n // Object.keys/for-in/`in` watchers on an index add.\n const companion = getOrCreateNode(nodes, isArray && isArrayIndexKey(key) ? 'length' : KEYS)\n batch(() => {\n keyNode[1]((v) => v + 1)\n companion[1]((v) => v + 1)\n })\n return true\n },\n\n has(target, key) {\n const nodes = getNodes(target)\n getOrCreateNode(nodes, KEYS)[0]()\n // Arrays notify index adds via the `length` node, not KEYS (see\n // `set`) — track it too so `'k' in arr` reacts to shape changes made\n // through an index write, not only through defineProperty-shaped adds.\n if (Array.isArray(target)) getOrCreateNode(nodes, 'length')[0]()\n return key in target\n },\n\n deleteProperty(target, key) {\n const had = key in target\n const ok = delete (target as Record<PropertyKey, unknown>)[key]\n if (had && ok) {\n const nodes = getNodes(target)\n const keyNode = nodes.get(key)\n const keysNode = getOrCreateNode(nodes, KEYS)\n batch(() => {\n if (keyNode !== undefined) keyNode[1]((v) => v + 1)\n keysNode[1]((v) => v + 1)\n })\n }\n return ok\n },\n\n ownKeys(target) {\n const nodes = getNodes(target)\n getOrCreateNode(nodes, KEYS)[0]()\n // See `has` above: array index adds bump `length`, not KEYS.\n if (Array.isArray(target)) getOrCreateNode(nodes, 'length')[0]()\n return Reflect.ownKeys(target)\n },\n}\n\n// ───────── Public core API (design §4.1) ─────────\n\n/**\n * Wrap a plain object/array in a fine-grained reactive tree. Idempotent and\n * identity-stable: `reactive(o) === reactive(o)`, `reactive(reactive(o)) ===\n * reactive(o)`. Non-wrappable values (Date, Map, Set, class instances,\n * frozen objects, primitives) are returned as-is.\n */\nexport function reactive<T extends object>(source: T): T {\n if (source === null || typeof source !== 'object') return source\n if (rawMap.has(source as object)) return source // already a proxy\n const cached = wrapMap.get(source as object)\n if (cached !== undefined) return cached as T\n if (!isWrappable(source as object)) return source\n const proxy = new Proxy(source as object, handlers) as T\n wrapMap.set(source as object, proxy as object)\n rawMap.set(proxy as object, source as object)\n return proxy\n}\n\n/** True for a proxy produced by `reactive()`. */\nexport function isReactive(value: unknown): boolean {\n return value !== null && typeof value === 'object' && rawMap.has(value as object)\n}\n\n/** The raw object behind a reactive proxy (O(1), no traversal — writes\n * unwrap, so the raw tree never contains proxies). Non-proxies pass\n * through unchanged. */\nexport function unwrap<T>(value: T): T {\n if (value !== null && typeof value === 'object') {\n const raw = rawMap.get(value as object)\n if (raw !== undefined) return raw as T\n }\n return value\n}\n\n/** Apply many writes as ONE flush. Equivalent to `batch(() => recipe(target))`\n * — the \"draft\" IS the reactive proxy; writes apply immediately (design\n * §8.6: NOT an Immer draft — a throwing recipe leaves partial writes, same\n * non-atomic-on-error posture `batch()` already documents). */\nexport function mutate<T extends object>(target: T, recipe: (draft: T) => void): void {\n batch(() => recipe(target))\n}\n\n// ───────── reconcile (design §4.1, §7.2, §9) ─────────\n\ntype ReconcileOptions = { key?: PropertyKey | ((item: unknown) => unknown) }\n\nfunction reconcileInto(proxy: object, next: unknown, options?: ReconcileOptions): void {\n const raw = unwrap(proxy)\n if (Array.isArray(raw) && Array.isArray(next)) {\n // `key` must apply at every array encountered during the recursion,\n // not only a top-level array (design intent: `reconcile(state, payload,\n // { key: 'id' })` where `state.rows` is a nested array) — falling back\n // to index matching here would silently re-identity rows on a reorder.\n if (options?.key !== undefined) {\n reconcileArrayKeyed(proxy as unknown[], raw, next, options)\n return\n }\n for (let i = 0; i < next.length; i++) {\n reconcileField(proxy, i, (raw as unknown[])[i], next[i], i < raw.length, options)\n }\n if (next.length < raw.length) (proxy as unknown[]).length = next.length\n return\n }\n if (isPlainObjectLike(raw) && isPlainObjectLike(next)) {\n for (const k of Reflect.ownKeys(next)) {\n reconcileField(proxy, k, raw[k], next[k], Object.hasOwn(raw, k), options)\n }\n for (const k of Reflect.ownKeys(raw)) {\n if (!Object.hasOwn(next, k)) {\n delete (proxy as Record<PropertyKey, unknown>)[k]\n }\n }\n }\n}\n\n/** Reconcile one field. Reads for comparison come from the RAW values\n * (never through the proxy) so this can run safely inside the tracking\n * effect that drives `reactiveComputed` without the write-side of this\n * same reconcile becoming a read-side dependency of itself. Only the\n * write path (`proxy[key] = …`) touches the proxy.\n *\n * `hadKey` mirrors the `set` trap's own add-detection fix: `curVal` for a\n * key genuinely absent from `raw` reads as `undefined`, same as an\n * explicit `undefined` value would — `Object.is` alone can't tell \"already\n * undefined\" from \"never existed\" apart, so a payload adding an\n * explicitly-`undefined`-valued key would otherwise be silently dropped as\n * a no-op. */\nfunction reconcileField(\n proxy: object,\n key: PropertyKey,\n curVal: unknown,\n nextVal: unknown,\n hadKey: boolean,\n options?: ReconcileOptions,\n): void {\n if (sameContainerShape(curVal, nextVal)) {\n reconcileInto(reactive(curVal as object), nextVal, options)\n } else if (!hadKey || !Object.is(curVal, nextVal)) {\n ;(proxy as Record<PropertyKey, unknown>)[key] = nextVal\n }\n}\n\nfunction reconcileArrayKeyed(\n proxy: unknown[],\n raw: unknown[],\n next: unknown[],\n options: ReconcileOptions,\n): void {\n const keyOpt = options.key as PropertyKey | ((item: unknown) => unknown)\n const idOf =\n typeof keyOpt === 'function'\n ? keyOpt\n : (item: unknown) => (item as Record<PropertyKey, unknown> | null)?.[keyOpt]\n const byKey = new Map<unknown, unknown>()\n for (const item of raw) byKey.set(idOf(item), item)\n const merged: unknown[] = new Array(next.length)\n for (let i = 0; i < next.length; i++) {\n const nextItem = next[i]\n const id = idOf(nextItem)\n const match = byKey.get(id)\n if (match !== undefined && sameContainerShape(match, nextItem)) {\n reconcileInto(reactive(match as object), nextItem, options)\n merged[i] = match\n // Consume the match: a duplicate key in `next` must NOT alias a\n // second array slot onto the same raw object (that would silently\n // make `proxy[i] === proxy[j]`, so a later write to one row mutates\n // the other). Unmatched duplicates fall through to a fresh value.\n byKey.delete(id)\n } else {\n merged[i] = nextItem\n }\n }\n for (let i = 0; i < merged.length; i++) {\n if (!Object.is(raw[i], merged[i])) proxy[i] = merged[i]\n }\n if (merged.length < raw.length) proxy.length = merged.length\n}\n\n/**\n * Merge `next` into `target` in place, preserving node identity for\n * unchanged values and notifying ONLY changed paths. The hydration /\n * refetch primitive. `key` controls array item matching (default: index),\n * applied recursively to every array reconcile encounters, not only a\n * top-level one.\n */\nexport function reconcile<T extends object>(target: T, next: T, options?: ReconcileOptions): void {\n const proxy = reactive(target)\n batch(() => {\n reconcileInto(proxy as object, next, options)\n })\n}\n\n// ───────── Test-only introspection ─────────\n//\n// NOT re-exported from index.ts (the \".\" entry stays exactly the public\n// API above) — reached only via a direct relative import from tests, the\n// same pattern packages/signals/src/signal.ts uses for `__hostOf` /\n// `__inspectGraph`. Since index.ts never references this binding, it is\n// dead-code-eliminated out of the measured `dist/index.js` (design §5,\n// §12 acceptance #1/#2 — this must never move the size row).\n\n/** @internal — test-only: the read function of the per-key tracking node\n * for `(raw, key)`, or `undefined` if that key has never been touched\n * through the proxy's `get`/`set` trap. Compose with `@aihu/signals`'\n * `__hostOf`/`__inspectGraph` to assert §2.7's allocation contract. */\nexport function __nodeOf(raw: object, key: PropertyKey): (() => number) | undefined {\n return nodeMap.get(raw)?.get(key)?.[0]\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@aihu/reactive",
3
+ "version": "0.2.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./helpers": {
15
+ "types": "./dist/helpers.d.ts",
16
+ "import": "./dist/helpers.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "sideEffects": false,
25
+ "dependencies": {
26
+ "@aihu/signals": "0.5.0"
27
+ },
28
+ "scripts": {
29
+ "build": "rm -rf dist && rolldown -c",
30
+ "test": "vitest run",
31
+ "typecheck": "tsc --noEmit",
32
+ "prepublishOnly": "bun run build"
33
+ },
34
+ "description": "Fine-grained Proxy-backed deep reactive trees on aihu signals — lazy per-(object,key) tracking nodes, plain-assignment writes, mutate/reconcile.",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/fellwork/aihu.git",
38
+ "directory": "packages/reactive"
39
+ },
40
+ "homepage": "https://github.com/fellwork/aihu/tree/main/packages/reactive#readme",
41
+ "bugs": "https://github.com/fellwork/aihu/issues",
42
+ "publishConfig": {
43
+ "access": "public"
44
+ }
45
+ }