@dimina-kit/inspect 0.4.0-dev.20260716214527 → 0.4.0-dev.20260718085557

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.
@@ -0,0 +1,265 @@
1
+ // The AppData tree: one page's merged setData state as a collapsible,
2
+ // optionally editable tree (WeChat DevTools AppData semantics). Pure
3
+ // presentation + local expansion/edit state; committed edits are reported
4
+ // upward as (path, next, prev) — the panel owns the undo/redo stack and the
5
+ // actual write-back, and the rendered VALUES always come from `root`, never
6
+ // from a local echo of an edit.
7
+ import React, { useState } from 'react'
8
+
9
+ /** One expand/collapse-all toolbar action. Every keepalive tree instance sees
10
+ * the same command object; a tree applies it only when `bridgeId` is its own
11
+ * and `seq` is unseen — a background tree must not replay a command that was
12
+ * aimed at the tree visible when the button was clicked. */
13
+ export interface AppDataTreeCommand {
14
+ seq: number
15
+ mode: 'expanded' | 'collapsed'
16
+ bridgeId: string
17
+ }
18
+
19
+ export interface AppDataTreeProps {
20
+ root: Record<string, unknown>
21
+ bridgeId: string
22
+ command: AppDataTreeCommand | null
23
+ /** Absent → read-only tree (no checkboxes, double-click is inert). */
24
+ onCommit?: (path: string, next: unknown, prev: unknown) => void
25
+ }
26
+
27
+ /** WeChat setData path syntax: dots between keys, indices in brackets. */
28
+ function childPath(parent: string, key: string | number): string {
29
+ if (typeof key === 'number') return `${parent}[${key}]`
30
+ return parent === '' ? key : `${parent}.${key}`
31
+ }
32
+
33
+ function isContainer(v: unknown): v is object {
34
+ return typeof v === 'object' && v !== null
35
+ }
36
+
37
+ function sortedEntries(value: object): Array<[string | number, unknown]> {
38
+ // Array.isArray narrows to any[]; the explicit unknown[] view keeps the
39
+ // elements typed (type-coverage counts every any-typed identifier).
40
+ if (Array.isArray(value)) return (value as unknown[]).map((v, i) => [i, v])
41
+ return Object.keys(value).sort().map((k) => [k, (value as Record<string, unknown>)[k]])
42
+ }
43
+
44
+ function countLabel(value: object): string {
45
+ return Array.isArray(value) ? `[${value.length}]` : `{${Object.keys(value).length}}`
46
+ }
47
+
48
+ // default: only the root row is open; expanded/collapsed: everything is.
49
+ type Baseline = 'default' | 'expanded' | 'collapsed'
50
+ const ROOT_PATH = ''
51
+
52
+ interface TreeState {
53
+ baseline: Baseline
54
+ overrides: Map<string, boolean>
55
+ editingPath: string | null
56
+ draft: string
57
+ }
58
+
59
+ function isExpanded(state: TreeState, path: string): boolean {
60
+ const override = state.overrides.get(path)
61
+ if (override !== undefined) return override
62
+ if (state.baseline === 'expanded') return true
63
+ if (state.baseline === 'collapsed') return false
64
+ return path === ROOT_PATH
65
+ }
66
+
67
+ interface NodeContext {
68
+ state: TreeState
69
+ toggle: (path: string, value: unknown) => void
70
+ beginEdit: (path: string, initial: string) => void
71
+ setDraft: (draft: string) => void
72
+ endEdit: () => void
73
+ onCommit?: (path: string, next: unknown, prev: unknown) => void
74
+ }
75
+
76
+ function ValueCell({ path, value, editable, ctx }: {
77
+ path: string
78
+ value: unknown
79
+ editable: boolean
80
+ ctx: NodeContext
81
+ }) {
82
+ if (typeof value === 'boolean') {
83
+ return (
84
+ <span className="inline-flex items-center gap-1 text-code-keyword">
85
+ {editable && (
86
+ <input
87
+ type="checkbox"
88
+ checked={value}
89
+ onChange={() => ctx.onCommit?.(path, !value, value)}
90
+ className="accent-accent"
91
+ />
92
+ )}
93
+ {String(value)}
94
+ </span>
95
+ )
96
+ }
97
+ if (value === null || value === undefined) {
98
+ return <span className="text-code-keyword">{String(value)}</span>
99
+ }
100
+ if (ctx.state.editingPath === path) {
101
+ const commit = (): void => {
102
+ const prev = value
103
+ if (typeof prev === 'number') {
104
+ const draft = ctx.state.draft.trim()
105
+ const next = Number(draft)
106
+ ctx.endEdit()
107
+ // Only finite numbers commit: Infinity/-Infinity (and 1e309-style
108
+ // overflow) are not serializable AppData values.
109
+ if (draft === '' || !Number.isFinite(next)) return
110
+ ctx.onCommit?.(path, next, prev)
111
+ return
112
+ }
113
+ const next = ctx.state.draft
114
+ ctx.endEdit()
115
+ ctx.onCommit?.(path, next, prev)
116
+ }
117
+ return (
118
+ <input
119
+ type="text"
120
+ autoFocus
121
+ value={ctx.state.draft}
122
+ onChange={(e) => ctx.setDraft(e.target.value)}
123
+ onKeyDown={(e) => {
124
+ if (e.key === 'Enter') commit()
125
+ else if (e.key === 'Escape') ctx.endEdit()
126
+ }}
127
+ onBlur={commit}
128
+ className="bg-surface-3 border border-accent rounded px-1 text-[12px] font-mono min-w-0 w-40"
129
+ />
130
+ )
131
+ }
132
+ const color = typeof value === 'number'
133
+ ? { color: 'var(--color-code-number)' }
134
+ : undefined
135
+ return (
136
+ <span
137
+ data-testid="appdata-value"
138
+ className={typeof value === 'string' ? 'text-code-orange' : undefined}
139
+ style={color}
140
+ onDoubleClick={editable ? () => ctx.beginEdit(path, String(value)) : undefined}
141
+ >
142
+ {String(value)}
143
+ </span>
144
+ )
145
+ }
146
+
147
+ /** True when a key segment would be re-parsed by the runtime's lodash-style
148
+ * `toPath` (dots / brackets), or silently DROPPED by it (empty keys — toPath
149
+ * never pushes an empty segment, so `profile.` parses to just `['profile']`
150
+ * and a write would overwrite the parent). Array indices are numbers and
151
+ * always safe. */
152
+ function segmentUnsafe(key: string | number): boolean {
153
+ return typeof key === 'string' && (key === '' || /[.[\]]/.test(key))
154
+ }
155
+
156
+ function TreeNode({ path, label, value, depth, unsafeSegments, ctx }: {
157
+ path: string
158
+ label: string
159
+ value: unknown
160
+ depth: number
161
+ /** Some segment on this node's path (its own key included) contains `.`/`[`/`]`. */
162
+ unsafeSegments: boolean
163
+ ctx: NodeContext
164
+ }) {
165
+ const indent = { paddingLeft: depth * 14 }
166
+ if (isContainer(value)) {
167
+ const open = isExpanded(ctx.state, path)
168
+ return (
169
+ <div>
170
+ <div
171
+ className="flex items-center gap-1 px-2 py-px cursor-pointer hover:bg-surface-3 text-[12px] font-mono"
172
+ style={indent}
173
+ onClick={() => ctx.toggle(path, value)}
174
+ >
175
+ <span className="text-text-secondary w-3 shrink-0 text-center select-none">
176
+ {open ? '▾' : '▸'}
177
+ </span>
178
+ <span className="text-code-blue">{label}</span>
179
+ <span className="text-text-secondary">{countLabel(value)}</span>
180
+ </div>
181
+ {open && sortedEntries(value).map(([key, child]) => (
182
+ <TreeNode
183
+ key={String(key)}
184
+ path={childPath(path, key)}
185
+ label={String(key)}
186
+ value={child}
187
+ depth={depth + 1}
188
+ unsafeSegments={unsafeSegments || segmentUnsafe(key)}
189
+ ctx={ctx}
190
+ />
191
+ ))}
192
+ </div>
193
+ )
194
+ }
195
+ const primitive = typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
196
+ // A multi-segment path with an unsafe segment cannot round-trip through the
197
+ // runtime's string-path `set()` — the patch key would be re-split on the
198
+ // dots/brackets inside the key and write a DIFFERENT field. A single-segment
199
+ // (top-level) key is safe regardless of content: the runtime's own-key check
200
+ // short-circuits before path parsing. `__proto__` is unwritable at ANY
201
+ // depth: the runtime's isUnsafeProperty drops the write outright. Unsafe
202
+ // rows render read-only.
203
+ const pathAmbiguous = (depth > 1 && unsafeSegments) || label === '__proto__'
204
+ const editable = primitive && ctx.onCommit !== undefined && !pathAmbiguous
205
+ return (
206
+ <div
207
+ className="flex items-center gap-1 px-2 py-px text-[12px] font-mono"
208
+ style={indent}
209
+ {...(editable ? { 'data-path': path } : {})}
210
+ >
211
+ <span className="w-3 shrink-0" />
212
+ <span className="text-code-blue">{label}</span>
213
+ <span className="text-text-secondary">:</span>
214
+ <ValueCell path={path} value={value} editable={editable} ctx={ctx} />
215
+ </div>
216
+ )
217
+ }
218
+
219
+ export function AppDataTree({ root, bridgeId, command, onCommit }: AppDataTreeProps) {
220
+ const [baseline, setBaseline] = useState<Baseline>('default')
221
+ const [overrides, setOverrides] = useState<Map<string, boolean>>(new Map())
222
+ const [appliedSeq, setAppliedSeq] = useState(0)
223
+ const [editingPath, setEditingPath] = useState<string | null>(null)
224
+ const [draft, setDraft] = useState('')
225
+
226
+ // Applying a toolbar command during render (state-adjust-in-render, same
227
+ // pattern as useActiveBridgeId) keeps the frame it lands on consistent.
228
+ if (command && command.bridgeId === bridgeId && command.seq !== appliedSeq) {
229
+ setBaseline(command.mode)
230
+ setOverrides(new Map())
231
+ setAppliedSeq(command.seq)
232
+ }
233
+
234
+ const state: TreeState = { baseline, overrides, editingPath, draft }
235
+ const ctx: NodeContext = {
236
+ state,
237
+ toggle: (path, value) => {
238
+ const next = new Map(overrides)
239
+ const opening = !isExpanded(state, path)
240
+ next.set(path, opening)
241
+ // Opening an array also opens its container elements: the elements are
242
+ // anonymous index rows, so surfacing their fields in the same click is
243
+ // what makes `list[0].id` reachable without a second dig.
244
+ if (opening && Array.isArray(value)) {
245
+ ;(value as unknown[]).forEach((element, i) => {
246
+ if (isContainer(element)) next.set(childPath(path, i), true)
247
+ })
248
+ }
249
+ setOverrides(next)
250
+ },
251
+ beginEdit: (path, initial) => {
252
+ setEditingPath(path)
253
+ setDraft(initial)
254
+ },
255
+ setDraft,
256
+ endEdit: () => setEditingPath(null),
257
+ onCommit,
258
+ }
259
+
260
+ return (
261
+ <div data-testid="appdata-tree" className="py-1">
262
+ <TreeNode path={ROOT_PATH} label="object" value={root} depth={0} unsafeSegments={false} ctx={ctx} />
263
+ </div>
264
+ )
265
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * AppDataPanel — a single write gate admits at most one in-flight write
3
+ * across every write path (commit and replay share it): while a write is
4
+ * pending, any further commit or replay request is dropped outright — no
5
+ * local echo, no queued follow-up, no second `onSetData` call. This is what
6
+ * keeps undo/redo stack order honest when a click races an in-flight async
7
+ * write:
8
+ *
9
+ * 1. A commit fired while a replay (撤销/重做) is in flight is dropped, so a
10
+ * resolving replay can never re-add a record that a same-turn commit
11
+ * already invalidated by clearing the redo stack.
12
+ * 2. A second commit fired while the first is still in flight is dropped, so
13
+ * the undo stack only ever grows by the write that was actually
14
+ * dispatched — never out of commit order.
15
+ */
16
+ import { describe, expect, it, vi } from 'vitest'
17
+ import { fireEvent, render, waitFor, within } from '@testing-library/react'
18
+ import { AppDataPanel, type AppDataPanelState as AppDataState } from './appdata-panel-view.js'
19
+
20
+ function writableState(): AppDataState {
21
+ return {
22
+ bridges: [{ id: 'b1', pagePath: '/pages/index/index' }],
23
+ activeBridgeId: 'b1',
24
+ entries: {
25
+ b1: {
26
+ comp: {
27
+ flag: true,
28
+ label: 'hello',
29
+ seed: 'S0',
30
+ a: 'A0',
31
+ b: 'B0',
32
+ },
33
+ },
34
+ },
35
+ }
36
+ }
37
+
38
+ function checkboxFor(container: HTMLElement, path: string): HTMLInputElement {
39
+ const row = container.querySelector(`[data-path="${path}"]`) as HTMLElement
40
+ return row.querySelector('input[type="checkbox"]') as HTMLInputElement
41
+ }
42
+
43
+ function valueElFor(container: HTMLElement, path: string): HTMLElement {
44
+ const row = container.querySelector(`[data-path="${path}"]`) as HTMLElement
45
+ return within(row).getByTestId('appdata-value')
46
+ }
47
+
48
+ /** Double-click a string/number row, replace its text, and commit on Enter. */
49
+ function commitText(container: HTMLElement, path: string, value: string): void {
50
+ fireEvent.dblClick(valueElFor(container, path))
51
+ const input = within(container).getByRole('textbox') as HTMLInputElement
52
+ fireEvent.change(input, { target: { value } })
53
+ fireEvent.keyDown(input, { key: 'Enter' })
54
+ }
55
+
56
+ /** Resolves/rejects on demand — lets a test hold `onSetData` open mid-dispatch
57
+ * to exercise pending/reentrancy behavior before settling it. */
58
+ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
59
+ let resolve!: (value: T) => void
60
+ const promise = new Promise<T>((res) => { resolve = res })
61
+ return { promise, resolve }
62
+ }
63
+
64
+ describe('AppDataPanel: a commit is dropped while a replay is in flight', () => {
65
+ it('never calls onSetData for the dropped commit, and settles into a single redo record — never a resurrected one', async () => {
66
+ const onSetData = vi.fn<(bridgeId: string, patch: Record<string, unknown>) => boolean | Promise<boolean>>(() => true)
67
+ const { container, getByTitle } = render(
68
+ <AppDataPanel state={writableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
69
+ )
70
+ fireEvent.click(checkboxFor(container, 'flag'))
71
+ expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(false)
72
+
73
+ const write = deferred<boolean>()
74
+ onSetData.mockImplementation(() => write.promise)
75
+ onSetData.mockClear()
76
+
77
+ fireEvent.click(getByTitle('撤销'))
78
+ expect(onSetData).toHaveBeenCalledTimes(1)
79
+
80
+ // A commit on an unrelated field lands mid-replay — the write gate must
81
+ // drop it before it ever reaches onSetData.
82
+ commitText(container, 'label', 'ignored')
83
+ expect(onSetData).toHaveBeenCalledTimes(1)
84
+
85
+ write.resolve(true)
86
+ await waitFor(() => expect((getByTitle('重做') as HTMLButtonElement).disabled).toBe(false))
87
+ expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(true)
88
+ // Stack state matches "the dropped commit never happened": exactly the
89
+ // one replayed record moved to redo, nothing extra came back onto it.
90
+ expect(onSetData).toHaveBeenCalledTimes(1)
91
+
92
+ fireEvent.click(getByTitle('重做'))
93
+ await waitFor(() => expect((getByTitle('重做') as HTMLButtonElement).disabled).toBe(true))
94
+ expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(false)
95
+ })
96
+ })
97
+
98
+ describe('AppDataPanel: a second commit is dropped while the first is in flight', () => {
99
+ it('calls onSetData once for the in-flight commit, disables 撤销/重做 while it is pending even with a nonempty stack, and undoes only the write that was actually dispatched', async () => {
100
+ const onSetData = vi.fn<(bridgeId: string, patch: Record<string, unknown>) => boolean | Promise<boolean>>(() => true)
101
+ const { container, getByTitle } = render(
102
+ <AppDataPanel state={writableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
103
+ )
104
+ // Seed a synchronously-settled undo record so the stack is nonempty
105
+ // before the race below — otherwise "disabled" would be indistinguishable
106
+ // from an empty-stack disable, not the write gate's pending state.
107
+ commitText(container, 'seed', 'S1')
108
+ expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(false)
109
+
110
+ const pending: Array<(value: boolean) => void> = []
111
+ onSetData.mockImplementation(() => new Promise<boolean>((resolve) => { pending.push(resolve) }))
112
+ onSetData.mockClear()
113
+
114
+ commitText(container, 'a', 'A1')
115
+ expect(onSetData).toHaveBeenCalledTimes(1)
116
+ // The write gate's pending state disables both buttons even though the
117
+ // undo stack is nonempty — an in-flight commit holds the gate exactly
118
+ // like an in-flight replay does.
119
+ expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(true)
120
+ expect((getByTitle('重做') as HTMLButtonElement).disabled).toBe(true)
121
+
122
+ commitText(container, 'b', 'B1')
123
+ expect(onSetData).toHaveBeenCalledTimes(1)
124
+
125
+ pending[0]?.(true)
126
+ await waitFor(() => expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(false))
127
+ expect(onSetData).toHaveBeenCalledTimes(1)
128
+
129
+ fireEvent.click(getByTitle('撤销'))
130
+ // Only `a` was ever committed — `b` never reached onSetData — so the
131
+ // record undo replays is necessarily `a`'s.
132
+ expect(onSetData).toHaveBeenLastCalledWith('b1', { a: 'A0' })
133
+ })
134
+ })
@@ -55,6 +55,14 @@ async function flush() {
55
55
  })
56
56
  }
57
57
 
58
+ /** Finds a Pages sidebar row by its rendered label (pagePath, or id when pagePath is null). */
59
+ function pageItem(label: string): HTMLElement {
60
+ const items = screen.getAllByTestId('appdata-page-item')
61
+ const found = items.find(el => (el.textContent ?? '').trim() === label)
62
+ if (!found) throw new Error(`no page item labeled "${label}" among [${items.map(el => el.textContent).join(', ')}]`)
63
+ return found
64
+ }
65
+
58
66
  afterEach(() => {
59
67
  cleanup()
60
68
  vi.unstubAllGlobals()
@@ -254,7 +262,7 @@ describe('ConnectedAppDataPanel: rendering contract', () => {
254
262
  expect(screen.getByTestId('appdata-panel').textContent).toContain('暂无页面数据(仅显示 Page 级 data)')
255
263
  })
256
264
 
257
- it('hides the tab bar when there is only a single bridge', async () => {
265
+ it('renders the Pages sidebar with a single item even when there is only one bridge', async () => {
258
266
  const { source, getSnapshotQueue } = createFakeSource()
259
267
  getSnapshotQueue.push(Promise.resolve(snapshot([{ id: 'b1', pagePath: '/pages/index/index' }], {
260
268
  b1: { 'pages/index/index': { count: 1 } },
@@ -262,10 +270,11 @@ describe('ConnectedAppDataPanel: rendering contract', () => {
262
270
  render(<ConnectedAppDataPanel source={source} />)
263
271
  await flush()
264
272
 
265
- expect(screen.queryByTitle('b1')).toBeNull()
273
+ expect(screen.getByTestId('appdata-pages')).not.toBeNull()
274
+ expect(screen.getAllByTestId('appdata-page-item')).toHaveLength(1)
266
275
  })
267
276
 
268
- it('renders one tab button per bridge, labeled by pagePath and falling back to id when pagePath is null', async () => {
277
+ it('renders one Pages sidebar item per bridge, labeled by pagePath and falling back to id when pagePath is null', async () => {
269
278
  const { source, getSnapshotQueue } = createFakeSource()
270
279
  getSnapshotQueue.push(Promise.resolve(snapshot([
271
280
  { id: 'b1', pagePath: '/pages/index/index' },
@@ -277,10 +286,9 @@ describe('ConnectedAppDataPanel: rendering contract', () => {
277
286
  render(<ConnectedAppDataPanel source={source} />)
278
287
  await flush()
279
288
 
280
- const tab1 = screen.getByTitle('b1')
281
- const tab2 = screen.getByTitle('b2')
282
- expect(tab1.textContent).toBe('/pages/index/index')
283
- expect(tab2.textContent).toBe('b2')
289
+ const labels = screen.getAllByTestId('appdata-page-item').map(el => (el.textContent ?? '').trim())
290
+ expect(labels).toContain('/pages/index/index')
291
+ expect(labels).toContain('b2')
284
292
  })
285
293
 
286
294
  it('keeps every bridge container mounted and toggles display instead of unmounting on tab switch', async () => {
@@ -303,7 +311,7 @@ describe('ConnectedAppDataPanel: rendering contract', () => {
303
311
  expect(c2.style.display).toBe('flex')
304
312
  expect(c1.style.display).toBe('none')
305
313
 
306
- fireEvent.click(screen.getByTitle('b1'))
314
+ fireEvent.click(pageItem('/pages/index/index'))
307
315
 
308
316
  expect(c1.style.display).toBe('flex')
309
317
  expect(c2.style.display).toBe('none')
@@ -312,7 +320,7 @@ describe('ConnectedAppDataPanel: rendering contract', () => {
312
320
  expect(document.querySelector('[data-bridge-id="b2"]')).toBe(c2)
313
321
  })
314
322
 
315
- it('renders one data section per entry key, headed by the key itself', async () => {
323
+ it('merges every entry\'s data into one tree so no bridge data is lost through the connected wiring', async () => {
316
324
  const { source, getSnapshotQueue } = createFakeSource()
317
325
  getSnapshotQueue.push(Promise.resolve(snapshot([{ id: 'b1', pagePath: null }], {
318
326
  b1: {
@@ -323,9 +331,9 @@ describe('ConnectedAppDataPanel: rendering contract', () => {
323
331
  render(<ConnectedAppDataPanel source={source} />)
324
332
  await flush()
325
333
 
326
- const container = within(document.querySelector('[data-bridge-id="b1"]') as HTMLElement)
327
- expect(container.getByText('pages/index/index')).not.toBeNull()
328
- expect(container.getByText('components/foo/foo')).not.toBeNull()
334
+ const tree = within(document.querySelector('[data-bridge-id="b1"]') as HTMLElement).getByTestId('appdata-tree')
335
+ expect(tree.textContent).toContain('count')
336
+ expect(tree.textContent).toContain('visible')
329
337
  })
330
338
  })
331
339
 
@@ -373,7 +381,7 @@ describe('ConnectedAppDataPanel: active bridge auto-follow', () => {
373
381
  render(<ConnectedAppDataPanel source={source} />)
374
382
  await flush()
375
383
 
376
- fireEvent.click(screen.getByTitle('b1'))
384
+ fireEvent.click(pageItem('/pages/index/index'))
377
385
  expect((document.querySelector('[data-bridge-id="b1"]') as HTMLElement).style.display).toBe('flex')
378
386
 
379
387
  act(() => {
@@ -393,7 +401,7 @@ describe('ConnectedAppDataPanel: active bridge auto-follow', () => {
393
401
  render(<ConnectedAppDataPanel source={source} />)
394
402
  await flush()
395
403
 
396
- fireEvent.click(screen.getByTitle('b1'))
404
+ fireEvent.click(pageItem('/pages/index/index'))
397
405
  expect((document.querySelector('[data-bridge-id="b1"]') as HTMLElement).style.display).toBe('flex')
398
406
 
399
407
  act(() => {
@@ -415,7 +423,7 @@ describe('ConnectedAppDataPanel: active bridge auto-follow', () => {
415
423
  await flush()
416
424
  expect((document.querySelector('[data-bridge-id="b1"]') as HTMLElement).style.display).toBe('flex')
417
425
 
418
- fireEvent.click(screen.getByTitle('b2'))
426
+ fireEvent.click(pageItem('/pages/detail/detail'))
419
427
  expect((document.querySelector('[data-bridge-id="b2"]') as HTMLElement).style.display).toBe('flex')
420
428
 
421
429
  rerender(<ConnectedAppDataPanel source={source} activePagePath="/pages/detail/detail" />)
@@ -52,11 +52,21 @@ export function ConnectedAppDataPanel({
52
52
 
53
53
  const { activeBridgeId, setActiveBridge } = useActiveBridgeId(snapshot.bridges, activePagePath)
54
54
 
55
+ // Only a source with a write-back channel makes the tree editable. The
56
+ // dispatch result flows through to the panel: its undo/redo stacks only
57
+ // advance when the runtime actually accepted the write. The authoritative
58
+ // new VALUE still arrives back through the snapshot push, never locally.
59
+ const setData = source.setData?.bind(source)
60
+ const onSetData = setData
61
+ ? (bridgeId: string, patch: Record<string, unknown>): Promise<boolean> => setData(bridgeId, patch)
62
+ : undefined
63
+
55
64
  return (
56
65
  <AppDataPanel
57
66
  state={{ bridges: snapshot.bridges, activeBridgeId, entries: snapshot.entries }}
58
67
  onSelectBridge={setActiveBridge}
59
68
  isRuntimeRunning={isRuntimeRunning}
69
+ onSetData={onSetData}
60
70
  />
61
71
  )
62
72
  }