@treenity/react 3.0.1 → 3.0.3

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.
Files changed (100) hide show
  1. package/README.md +1 -1
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +33 -7
  4. package/dist/App.js.map +1 -1
  5. package/dist/ComponentSection.js +1 -1
  6. package/dist/ComponentSection.js.map +1 -1
  7. package/dist/ErrorBoundary.d.ts.map +1 -1
  8. package/dist/ErrorBoundary.js +2 -1
  9. package/dist/ErrorBoundary.js.map +1 -1
  10. package/dist/Treenity.d.ts +15 -0
  11. package/dist/Treenity.d.ts.map +1 -0
  12. package/dist/Treenity.js +17 -0
  13. package/dist/Treenity.js.map +1 -0
  14. package/dist/cache.d.ts.map +1 -1
  15. package/dist/cache.js +5 -0
  16. package/dist/cache.js.map +1 -1
  17. package/dist/client-tree.d.ts.map +1 -1
  18. package/dist/client-tree.js +2 -2
  19. package/dist/client-tree.js.map +1 -1
  20. package/dist/components/ui/button.d.ts +2 -2
  21. package/dist/components/ui/button.d.ts.map +1 -1
  22. package/dist/components/ui/button.js +3 -3
  23. package/dist/components/ui/button.js.map +1 -1
  24. package/dist/components/ui/pagination.d.ts +2 -2
  25. package/dist/components/ui/pagination.d.ts.map +1 -1
  26. package/dist/components/ui/pagination.js +3 -3
  27. package/dist/components/ui/pagination.js.map +1 -1
  28. package/dist/components/ui/textarea.js +1 -1
  29. package/dist/components/ui/textarea.js.map +1 -1
  30. package/dist/events.d.ts +2 -0
  31. package/dist/events.d.ts.map +1 -1
  32. package/dist/events.js +47 -2
  33. package/dist/events.js.map +1 -1
  34. package/dist/fiber-tree.d.ts.map +1 -1
  35. package/dist/fiber-tree.js.map +1 -1
  36. package/dist/hooks.d.ts +9 -0
  37. package/dist/hooks.d.ts.map +1 -1
  38. package/dist/hooks.js +80 -5
  39. package/dist/hooks.js.map +1 -1
  40. package/dist/lib/minimd.d.ts.map +1 -1
  41. package/dist/lib/minimd.js +8 -1
  42. package/dist/lib/minimd.js.map +1 -1
  43. package/dist/lib/sanitize-href.d.ts +3 -0
  44. package/dist/lib/sanitize-href.d.ts.map +1 -0
  45. package/dist/lib/sanitize-href.js +14 -0
  46. package/dist/lib/sanitize-href.js.map +1 -0
  47. package/dist/main.d.ts +2 -0
  48. package/dist/main.d.ts.map +1 -1
  49. package/dist/main.js +14 -5
  50. package/dist/main.js.map +1 -1
  51. package/dist/mods/editor-ui/FieldLabel.d.ts.map +1 -1
  52. package/dist/mods/editor-ui/FieldLabel.js +2 -1
  53. package/dist/mods/editor-ui/FieldLabel.js.map +1 -1
  54. package/dist/mods/editor-ui/default-edit.js +4 -2
  55. package/dist/mods/editor-ui/default-edit.js.map +1 -1
  56. package/dist/mods/editor-ui/form-field.d.ts.map +1 -1
  57. package/dist/mods/editor-ui/form-field.js +3 -2
  58. package/dist/mods/editor-ui/form-field.js.map +1 -1
  59. package/dist/mods/editor-ui/type-picker.d.ts.map +1 -1
  60. package/dist/mods/editor-ui/type-picker.js +2 -1
  61. package/dist/mods/editor-ui/type-picker.js.map +1 -1
  62. package/dist/mods/treenity/preview.d.ts.map +1 -1
  63. package/dist/mods/treenity/preview.js +2 -3
  64. package/dist/mods/treenity/preview.js.map +1 -1
  65. package/dist/mods/treenity/ref-view.js +2 -1
  66. package/dist/mods/treenity/ref-view.js.map +1 -1
  67. package/dist/mods/treenity/seed.js +3 -2
  68. package/dist/mods/treenity/seed.js.map +1 -1
  69. package/dist/symbols.d.ts.map +1 -1
  70. package/dist/symbols.js +11 -5
  71. package/dist/symbols.js.map +1 -1
  72. package/package.json +4 -2
  73. package/src/App.tsx +29 -1
  74. package/src/ComponentSection.tsx +1 -1
  75. package/src/ErrorBoundary.tsx +6 -3
  76. package/src/Treenity.tsx +32 -0
  77. package/src/cache.ts +7 -0
  78. package/src/client-tree.ts +7 -7
  79. package/src/components/ui/button.tsx +4 -5
  80. package/src/components/ui/pagination.tsx +4 -9
  81. package/src/components/ui/textarea.tsx +1 -1
  82. package/src/events.ts +46 -6
  83. package/src/fiber-tree.ts +3 -3
  84. package/src/hooks.ts +73 -4
  85. package/src/lib/minimd.ts +7 -1
  86. package/src/lib/sanitize-href.ts +13 -0
  87. package/src/main.tsx +23 -11
  88. package/src/mods/editor-ui/FieldLabel.tsx +5 -4
  89. package/src/mods/editor-ui/default-edit.tsx +6 -4
  90. package/src/mods/editor-ui/form-field.tsx +4 -2
  91. package/src/mods/editor-ui/type-picker.tsx +3 -2
  92. package/src/mods/treenity/preview.tsx +6 -7
  93. package/src/mods/treenity/ref-view.tsx +11 -6
  94. package/src/mods/treenity/seed.ts +3 -2
  95. package/src/symbols.ts +12 -5
  96. package/src/bind/bind.test.ts +0 -316
  97. package/src/cache.test.ts +0 -139
  98. package/src/client-tree.test.ts +0 -116
  99. package/src/optimistic.test.ts +0 -111
  100. package/src/remote-tree.test.ts +0 -142
package/src/hooks.ts CHANGED
@@ -99,7 +99,10 @@ export function useChildren(parentPath: string, opts?: WatchOpts) {
99
99
 
100
100
  trpc.getChildren
101
101
  .query({ path: parentPath, limit: opts?.limit, watch: opts?.watch, watchNew: opts?.watchNew })
102
- .then((result: any) => cache.putMany(result.items as NodeData[], parentPath));
102
+ .then((result: any) => {
103
+ if (result.truncated) console.warn(`[tree] Children of ${parentPath} truncated — results may be incomplete`);
104
+ cache.putMany(result.items as NodeData[], parentPath);
105
+ });
103
106
  }, [parentPath, gen, opts?.limit, opts?.watch, opts?.watchNew]);
104
107
 
105
108
  return useSyncExternalStore(
@@ -111,10 +114,17 @@ export function useChildren(parentPath: string, opts?: WatchOpts) {
111
114
  // ── set: optimistic update + server persist ──
112
115
 
113
116
  export async function set(next: NodeData) {
117
+ const prev = cache.get(next.$path);
114
118
  cache.put(next);
115
- await tree.set(next);
116
- const fresh = await tree.get(next.$path);
117
- if (fresh) cache.put(fresh);
119
+ try {
120
+ await tree.set(next);
121
+ const fresh = await tree.get(next.$path);
122
+ if (fresh) cache.put(fresh);
123
+ } catch (err) {
124
+ // F15: rollback optimistic cache on server reject (validation, ACL, OCC)
125
+ if (prev) cache.put(prev); else cache.remove(next.$path);
126
+ throw err;
127
+ }
118
128
  }
119
129
 
120
130
  // ── execute: action caller ──
@@ -168,6 +178,65 @@ export function useCanWrite(path: string | null): boolean {
168
178
  return (perm & W) !== 0;
169
179
  }
170
180
 
181
+ // ── useAutoSave: throttled auto-persist for editable views ──
182
+ // First save fires after 500ms (responsive), subsequent saves throttled to 2s.
183
+ // Returns [localData, setField, dirty] — local updates are instant, server writes are batched.
184
+
185
+ // TODO: check why unused
186
+ export function useAutoSave(node: NodeData) {
187
+ const [local, setLocal] = useState<Record<string, unknown>>({});
188
+ const dirtyRef = useRef(false);
189
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
190
+ const savingRef = useRef(false);
191
+ const pendingRef = useRef(false);
192
+ const lastSaveRef = useRef(0);
193
+ const nodeRef = useRef(node);
194
+ nodeRef.current = node;
195
+
196
+ const flush = useCallback(async () => {
197
+ if (savingRef.current) { pendingRef.current = true; return; }
198
+ if (!dirtyRef.current) return;
199
+ savingRef.current = true;
200
+ try {
201
+ const merged = { ...nodeRef.current, ...local };
202
+ delete merged.$rev; // skip OCC — force-write
203
+ await set(merged);
204
+ dirtyRef.current = false;
205
+ lastSaveRef.current = Date.now();
206
+ } catch (e) {
207
+ console.error('[autoSave] failed:', e);
208
+ } finally {
209
+ savingRef.current = false;
210
+ if (pendingRef.current) {
211
+ pendingRef.current = false;
212
+ flush();
213
+ }
214
+ }
215
+ }, [local]);
216
+
217
+ const setField = useCallback((field: string, value: unknown) => {
218
+ setLocal(prev => ({ ...prev, [field]: value }));
219
+ dirtyRef.current = true;
220
+
221
+ if (timerRef.current) clearTimeout(timerRef.current);
222
+ const elapsed = Date.now() - lastSaveRef.current;
223
+ const delay = elapsed > 2000 ? 500 : 2000; // first batch fast, then throttle
224
+ timerRef.current = setTimeout(() => { timerRef.current = null; flush(); }, delay);
225
+ }, [flush]);
226
+
227
+ // Flush on unmount
228
+ useEffect(() => () => {
229
+ if (timerRef.current) { clearTimeout(timerRef.current); flush(); }
230
+ }, [flush]);
231
+
232
+ // Reset local on node path change
233
+ useEffect(() => { setLocal({}); dirtyRef.current = false; }, [node.$path]);
234
+
235
+ const merged = useMemo(() => ({ ...node, ...local }), [node, local]);
236
+
237
+ return [merged, setField, dirtyRef.current] as const;
238
+ }
239
+
171
240
  // ── Internals ──
172
241
 
173
242
  const AsyncGenFn = Object.getPrototypeOf(async function* () { }).constructor;
package/src/lib/minimd.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Minimal markdown → HTML for chat bubbles (~50 lines)
2
2
  // Covers: headers, bold, italic, inline code, code blocks, links, lists, blockquotes, hr
3
3
  import './minimd.css';
4
+ import { sanitizeHref } from './sanitize-href';
4
5
 
5
6
  const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
6
7
 
@@ -11,7 +12,12 @@ function inline(s: string): string {
11
12
  .replace(/__(.+?)__/g, '<strong>$1</strong>')
12
13
  .replace(/\*(.+?)\*/g, '<em>$1</em>')
13
14
  .replace(/_(.+?)_/g, '<em>$1</em>')
14
- .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
15
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => {
16
+ const safe = sanitizeHref(url);
17
+ if (!safe) return text;
18
+ const safeUrl = safe.replace(/"/g, '&quot;');
19
+ return `<a href="${safeUrl}" target="_blank" rel="noopener">${text}</a>`;
20
+ });
15
21
  }
16
22
 
17
23
  export function minimd(src: string): string {
@@ -0,0 +1,13 @@
1
+ // Allowlist-based href sanitizer — blocks javascript:, data:, vbscript:, etc.
2
+ // Strips control characters before protocol check to defeat browser bypass vectors
3
+ // (e.g. "java\tscript:" → browsers collapse to "javascript:")
4
+
5
+ const SAFE_PROTOCOL = /^(https?:|mailto:|tel:|\/|#)/i;
6
+
7
+ /** Returns sanitized URL string, or null if the protocol is unsafe. */
8
+ export function sanitizeHref(url: string): string | null {
9
+ const trimmed = url.replace(/[\x00-\x20]+/g, '');
10
+ if (!trimmed) return null;
11
+ if (SAFE_PROTOCOL.test(trimmed)) return url.trim();
12
+ return null;
13
+ }
package/src/main.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import 'reflect-metadata';
2
2
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
3
  import { enablePatches } from 'immer';
4
- import { StrictMode } from 'react';
4
+ import { type ReactNode } from 'react';
5
5
  import { createRoot } from 'react-dom/client';
6
6
  import { App } from './App';
7
7
  import './load-client';
@@ -12,13 +12,25 @@ enablePatches();
12
12
 
13
13
  const queryClient = new QueryClient();
14
14
 
15
- const root = document.getElementById('root');
16
- if (!root) throw new Error('No #root element');
17
- createRoot(root).render(
18
- <StrictMode>
19
- <QueryClientProvider client={queryClient}>
20
- <App />
21
- <Toaster />
22
- </QueryClientProvider>
23
- </StrictMode>,
24
- );
15
+ // StrictMode off: FlowGram inversify container breaks on double-mount
16
+ // https://github.com/bytedance/flowgram.ai/issues/402
17
+ const Strict = ({ children }: { children: ReactNode }) => children;
18
+
19
+ /** Mount Treenity UI into a DOM element */
20
+ export function boot(el: HTMLElement | string = '#root') {
21
+ const root = typeof el === 'string' ? document.querySelector(el) : el;
22
+ if (!root) throw new Error(`Treenity boot: element "${el}" not found`);
23
+ createRoot(root as HTMLElement).render(
24
+ <Strict>
25
+ <QueryClientProvider client={queryClient}>
26
+ <App />
27
+ <Toaster />
28
+ </QueryClientProvider>
29
+ </Strict>,
30
+ );
31
+ }
32
+
33
+ // Auto-boot when loaded directly (not imported)
34
+ if (typeof document !== 'undefined' && document.getElementById('root')) {
35
+ boot('#root');
36
+ }
@@ -8,6 +8,7 @@ import {
8
8
  DropdownMenuSeparator,
9
9
  DropdownMenuTrigger,
10
10
  } from '#components/ui/dropdown-menu';
11
+ import { Input } from '#components/ui/input';
11
12
  import { isRef } from '@treenity/core';
12
13
  import { useState } from 'react';
13
14
 
@@ -101,8 +102,8 @@ export function RefEditor({ value, onChange }: {
101
102
  <div className="flex flex-col gap-1 flex-1 min-w-0">
102
103
  <div className="flex items-center gap-1">
103
104
  <span className="text-[9px] text-muted-foreground shrink-0 w-5">$ref</span>
104
- <input
105
- className="flex-1 min-w-0"
105
+ <Input
106
+ className="h-7 text-xs flex-1 min-w-0"
106
107
  value={value.$ref}
107
108
  onChange={(e) => onChange(hasMap ? { $ref: e.target.value, $map: value.$map } : { $ref: e.target.value })}
108
109
  placeholder="path"
@@ -111,8 +112,8 @@ export function RefEditor({ value, onChange }: {
111
112
  {hasMap && (
112
113
  <div className="flex items-center gap-1">
113
114
  <span className="text-[9px] text-muted-foreground shrink-0 w-5">$map</span>
114
- <input
115
- className="flex-1 min-w-0"
115
+ <Input
116
+ className="h-7 text-xs flex-1 min-w-0"
116
117
  value={value.$map ?? ''}
117
118
  onChange={(e) => onChange({ $ref: value.$ref, $map: e.target.value })}
118
119
  placeholder="field"
@@ -1,3 +1,5 @@
1
+ import { Checkbox } from '#components/ui/checkbox';
2
+ import { Input } from '#components/ui/input';
1
3
  import { useSchema } from '#schema-loader';
2
4
  import { type ComponentData, isRef, register, resolve } from '@treenity/core';
3
5
  import { createElement } from 'react';
@@ -57,12 +59,12 @@ function DefaultEditForm({ value, onChange }: { value: ComponentData; onChange?:
57
59
  <FieldLabel label={k} value={v} onChange={onCh} />
58
60
  {typeof v === 'boolean' ? (
59
61
  <label className="flex items-center gap-2 cursor-pointer">
60
- <input type="checkbox" checked={!!data[k]} className="w-auto"
61
- onChange={(e) => setData((prev) => ({ ...prev, [k]: e.target.checked }))} />
62
+ <Checkbox checked={!!data[k]}
63
+ onChange={(e) => setData((prev) => ({ ...prev, [k]: (e.target as HTMLInputElement).checked }))} />
62
64
  {data[k] ? 'true' : 'false'}
63
65
  </label>
64
66
  ) : typeof v === 'number' ? (
65
- <input type="number" value={String(data[k] ?? 0)}
67
+ <Input type="number" className="h-7 text-xs" value={String(data[k] ?? 0)}
66
68
  onChange={(e) => setData((prev) => ({ ...prev, [k]: Number(e.target.value) }))} />
67
69
  ) : Array.isArray(v) ? (
68
70
  <StringArrayField value={data[k] as unknown[]}
@@ -78,7 +80,7 @@ function DefaultEditForm({ value, onChange }: { value: ComponentData; onChange?:
78
80
  : <pre className="text-[11px] font-mono text-foreground/60">{JSON.stringify(data[k], null, 2)}</pre>;
79
81
  })()
80
82
  ) : (
81
- <input value={String(data[k] ?? '')}
83
+ <Input className="h-7 text-xs" value={String(data[k] ?? '')}
82
84
  onChange={(e) => setData((prev) => ({ ...prev, [k]: e.target.value }))} />
83
85
  )}
84
86
  </div>
@@ -1,5 +1,6 @@
1
1
  import { Button } from '#components/ui/button';
2
2
  import { Input } from '#components/ui/input';
3
+ import { Textarea } from '#components/ui/textarea';
3
4
  import { isRef, resolveExact } from '@treenity/core';
4
5
  import { createElement, useState } from 'react';
5
6
  import { FieldLabel, RefEditor } from './FieldLabel';
@@ -42,7 +43,7 @@ export function renderField(
42
43
  const handler = resolveExact(fieldSchema.type, ctx) ?? resolveExact('string', ctx);
43
44
  if (!handler)
44
45
  return (
45
- <div key={name} className="text-[--danger] text-xs">
46
+ <div key={name} className="text-destructive text-xs">
46
47
  No form handler: {fieldSchema.type}
47
48
  </div>
48
49
  );
@@ -91,7 +92,8 @@ export function StringArrayField({
91
92
 
92
93
  if (!isStrings) {
93
94
  return (
94
- <textarea
95
+ <Textarea
96
+ className="min-h-16 text-xs font-mono"
95
97
  value={JSON.stringify(value, null, 2)}
96
98
  onChange={(e) => {
97
99
  try {
@@ -1,6 +1,7 @@
1
1
  import { Button } from '#components/ui/button';
2
2
  import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '#components/ui/command';
3
3
  import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '#components/ui/dialog';
4
+ import { Input } from '#components/ui/input';
4
5
  import { trpc } from '#trpc';
5
6
  import { isOfType, type NodeData } from '@treenity/core';
6
7
  import { useEffect, useRef, useState } from 'react';
@@ -123,9 +124,9 @@ export function TypePicker({
123
124
  </Command>
124
125
 
125
126
  <div className="px-4 py-3 border-t border-border">
126
- <input
127
+ <Input
127
128
  ref={nameRef}
128
- className="w-full"
129
+ className="h-8 text-sm"
129
130
  placeholder={nameLabel}
130
131
  value={name}
131
132
  onChange={(e) => { setName(e.target.value); setNameManual(true); }}
@@ -1,5 +1,6 @@
1
1
  // Reusable type preview — Storybook-like: context switcher + live render + schema form editor
2
2
 
3
+ import { Button } from '#components/ui/button';
3
4
  import { Render, RenderContext } from '#context';
4
5
  import { type ComponentData, getContextsForType, type NodeData } from '@treenity/core';
5
6
  import { useMemo, useState } from 'react';
@@ -94,17 +95,15 @@ export function TypePreview({ typeName, properties }: {
94
95
  <div>
95
96
  <div className="flex gap-1.5 mb-2">
96
97
  {reactContexts.map(c => (
97
- <button
98
+ <Button
98
99
  key={c}
100
+ variant={previewCtx === c ? 'default' : 'outline'}
101
+ size="sm"
102
+ className="h-auto rounded-full px-2.5 py-0.5 text-xs font-mono"
99
103
  onClick={() => setPreviewCtx(prev => prev === c ? null : c)}
100
- className={`px-2.5 py-0.5 rounded-full text-xs font-mono border cursor-pointer transition-colors ${
101
- previewCtx === c
102
- ? 'bg-primary text-primary-foreground border-primary'
103
- : 'bg-muted border-border text-muted-foreground hover:border-primary/50'
104
- }`}
105
104
  >
106
105
  {c}
107
- </button>
106
+ </Button>
108
107
  ))}
109
108
  </div>
110
109
 
@@ -1,5 +1,6 @@
1
1
  // Ref node/component view — shows target path, resolve button, inline preview
2
2
 
3
+ import { Button } from '#components/ui/button';
3
4
  import { Render } from '#context';
4
5
  import { usePath } from '#hooks';
5
6
  import { type NodeData, register } from '@treenity/core';
@@ -45,20 +46,24 @@ function RefDisplay({ target, onSelect }: { target: string; onSelect?: (p: strin
45
46
  <span className="text-sm font-mono text-primary">{target}</span>
46
47
 
47
48
  {onSelect && (
48
- <button
49
- className="text-xs px-2 py-0.5 rounded bg-muted hover:bg-muted/80 text-foreground border border-border"
49
+ <Button
50
+ variant="outline"
51
+ size="sm"
52
+ className="h-auto px-2 py-0.5 text-xs"
50
53
  onClick={() => onSelect(target)}
51
54
  >
52
55
  Go to
53
- </button>
56
+ </Button>
54
57
  )}
55
58
 
56
- <button
57
- className="text-xs px-2 py-0.5 rounded bg-muted hover:bg-muted/80 text-foreground border border-border"
59
+ <Button
60
+ variant="outline"
61
+ size="sm"
62
+ className="h-auto px-2 py-0.5 text-xs"
58
63
  onClick={() => setResolved(!resolved)}
59
64
  >
60
65
  {resolved ? 'Collapse' : 'Resolve'}
61
- </button>
66
+ </Button>
62
67
  </div>
63
68
 
64
69
  {/* Resolved target */}
@@ -1,9 +1,9 @@
1
- import { type NodeData, R, S } from '@treenity/core';
1
+ import { type NodeData, A, R, S, W } from '@treenity/core';
2
2
  import { registerPrefab } from '@treenity/core/mod';
3
3
 
4
4
  registerPrefab('core', 'seed', [
5
5
  { $path: 'sys', $type: 'treenity.system' },
6
- { $path: 'auth', $type: 'dir' },
6
+ { $path: 'auth', $type: 'dir', $acl: [{ g: 'admins', p: R | W | A | S }, { g: 'public', p: 0 }] },
7
7
  { $path: 'auth/users', $type: 'mount-point',
8
8
  connection: { $type: 'connection', db: 'treenity', collection: 'users' },
9
9
  mount: { $type: 't.mount.mongo' },
@@ -20,6 +20,7 @@ registerPrefab('core', 'seed', [
20
20
  { $path: 'auth/sessions', $type: 'mount-point',
21
21
  connection: { $type: 'connection', db: 'treenity', collection: 'sessions' },
22
22
  mount: { $type: 't.mount.mongo' },
23
+ $acl: [{ g: 'admins', p: R | W | A | S }, { g: 'authenticated', p: 0 }, { g: 'public', p: 0 }],
23
24
  },
24
25
  { $path: 'mnt', $type: 'dir' },
25
26
  { $path: 'mnt/orders', $type: 't.mount.mongo',
package/src/symbols.ts CHANGED
@@ -1,18 +1,25 @@
1
1
  // Symbol-based component location metadata.
2
- // Stamped on deserialization (cache.put). Survive spread, invisible to JSON/keys/entries.
2
+ // Stamped on deserialization (cache.put). Non-enumerable so they're
3
+ // invisible to structuredClone, spread, and JSON/keys/entries.
3
4
 
4
5
  import { isComponent, type NodeData } from '@treenity/core';
5
6
 
6
7
  export const $key = Symbol.for('treenity.$key');
7
8
  export const $node = Symbol.for('treenity.$node');
8
9
 
10
+ function hide(obj: object, sym: symbol, value: unknown): void {
11
+ Object.defineProperty(obj, sym, { value, enumerable: false, writable: false, configurable: true });
12
+ }
13
+
9
14
  export function stampNode(node: NodeData): void {
10
- (node as any)[$key] = '';
11
- (node as any)[$node] = node;
15
+ if ((node as any)[$node] === node) return;
16
+
17
+ hide(node, $key, '');
18
+ hide(node, $node, node);
12
19
 
13
20
  for (const [k, v] of Object.entries(node)) {
14
21
  if (k.startsWith('$') || !isComponent(v)) continue;
15
- (v as any)[$key] = k;
16
- (v as any)[$node] = node;
22
+ hide(v, $key, k);
23
+ hide(v, $node, node);
17
24
  }
18
25
  }