@dxos/app-graph 0.8.4-main.b97322e → 0.8.4-main.bc2380dfbc

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 (72) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/neutral/chunk-J5LGTIGS.mjs +10 -0
  4. package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +7 -0
  5. package/dist/lib/neutral/chunk-WJJ5KEOH.mjs +1477 -0
  6. package/dist/lib/neutral/chunk-WJJ5KEOH.mjs.map +7 -0
  7. package/dist/lib/neutral/index.mjs +40 -0
  8. package/dist/lib/neutral/index.mjs.map +7 -0
  9. package/dist/lib/neutral/meta.json +1 -0
  10. package/dist/lib/neutral/scheduler.mjs +15 -0
  11. package/dist/lib/neutral/scheduler.mjs.map +7 -0
  12. package/dist/lib/neutral/testing/index.mjs +40 -0
  13. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  14. package/dist/types/src/atoms.d.ts +8 -0
  15. package/dist/types/src/atoms.d.ts.map +1 -0
  16. package/dist/types/src/graph-builder.d.ts +117 -60
  17. package/dist/types/src/graph-builder.d.ts.map +1 -1
  18. package/dist/types/src/graph.d.ts +188 -218
  19. package/dist/types/src/graph.d.ts.map +1 -1
  20. package/dist/types/src/index.d.ts +7 -3
  21. package/dist/types/src/index.d.ts.map +1 -1
  22. package/dist/types/src/node-matcher.d.ts +244 -0
  23. package/dist/types/src/node-matcher.d.ts.map +1 -0
  24. package/dist/types/src/node-matcher.test.d.ts +2 -0
  25. package/dist/types/src/node-matcher.test.d.ts.map +1 -0
  26. package/dist/types/src/node.d.ts +50 -5
  27. package/dist/types/src/node.d.ts.map +1 -1
  28. package/dist/types/src/scheduler.browser.d.ts +2 -0
  29. package/dist/types/src/scheduler.browser.d.ts.map +1 -0
  30. package/dist/types/src/scheduler.d.ts +8 -0
  31. package/dist/types/src/scheduler.d.ts.map +1 -0
  32. package/dist/types/src/stories/EchoGraph.stories.d.ts +8 -10
  33. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  34. package/dist/types/src/testing/index.d.ts +2 -0
  35. package/dist/types/src/testing/index.d.ts.map +1 -0
  36. package/dist/types/src/testing/setup-graph-builder.d.ts +31 -0
  37. package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -0
  38. package/dist/types/src/util.d.ts +40 -0
  39. package/dist/types/src/util.d.ts.map +1 -0
  40. package/dist/types/tsconfig.tsbuildinfo +1 -1
  41. package/package.json +54 -41
  42. package/src/atoms.ts +25 -0
  43. package/src/graph-builder.test.ts +1193 -126
  44. package/src/graph-builder.ts +753 -264
  45. package/src/graph.test.ts +451 -123
  46. package/src/graph.ts +1057 -407
  47. package/src/index.ts +10 -3
  48. package/src/node-matcher.test.ts +301 -0
  49. package/src/node-matcher.ts +314 -0
  50. package/src/node.ts +82 -8
  51. package/src/scheduler.browser.ts +5 -0
  52. package/src/scheduler.ts +17 -0
  53. package/src/stories/EchoGraph.stories.tsx +174 -135
  54. package/src/stories/Tree.tsx +1 -1
  55. package/src/testing/index.ts +5 -0
  56. package/src/testing/setup-graph-builder.ts +41 -0
  57. package/src/util.ts +101 -0
  58. package/dist/lib/browser/index.mjs +0 -778
  59. package/dist/lib/browser/index.mjs.map +0 -7
  60. package/dist/lib/browser/meta.json +0 -1
  61. package/dist/lib/node-esm/index.mjs +0 -780
  62. package/dist/lib/node-esm/index.mjs.map +0 -7
  63. package/dist/lib/node-esm/meta.json +0 -1
  64. package/dist/types/src/experimental/graph-projections.test.d.ts +0 -25
  65. package/dist/types/src/experimental/graph-projections.test.d.ts.map +0 -1
  66. package/dist/types/src/signals-integration.test.d.ts +0 -2
  67. package/dist/types/src/signals-integration.test.d.ts.map +0 -1
  68. package/dist/types/src/testing.d.ts +0 -5
  69. package/dist/types/src/testing.d.ts.map +0 -1
  70. package/src/experimental/graph-projections.test.ts +0 -56
  71. package/src/signals-integration.test.ts +0 -218
  72. package/src/testing.ts +0 -20
package/src/node.ts CHANGED
@@ -2,9 +2,30 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { type MaybePromise, type MakeOptional } from '@dxos/util';
5
+ import type * as Context from 'effect/Context';
6
+ import type * as Effect from 'effect/Effect';
6
7
 
7
- import { ACTION_GROUP_TYPE, ACTION_TYPE } from './graph';
8
+ import { type MakeOptional } from '@dxos/util';
9
+
10
+ /**
11
+ * Root node ID.
12
+ */
13
+ export const RootId = 'root';
14
+
15
+ /**
16
+ * Root node type.
17
+ */
18
+ export const RootType = 'org.dxos.type.graphRoot';
19
+
20
+ /**
21
+ * Action node type.
22
+ */
23
+ export const ActionType = 'org.dxos.type.graphAction';
24
+
25
+ /**
26
+ * Action group node type.
27
+ */
28
+ export const ActionGroupType = 'org.dxos.type.graphActionGroup';
8
29
 
9
30
  /**
10
31
  * Represents a node in the graph.
@@ -48,7 +69,19 @@ export type NodeFilter<TData = any, TProperties extends Record<string, any> = Re
48
69
  connectedNode: Node,
49
70
  ) => node is Node<TData, TProperties>;
50
71
 
51
- export type Relation = 'outbound' | 'inbound';
72
+ export type RelationDirection = 'outbound' | 'inbound';
73
+
74
+ export type Relation = Readonly<{
75
+ kind: string;
76
+ direction: RelationDirection;
77
+ }>;
78
+
79
+ export type RelationInput = Relation | string;
80
+
81
+ export const relation = (kind: string, direction: RelationDirection = 'outbound'): Relation => ({ kind, direction });
82
+ // TODO(wittjosiah): Consider moving these helpers out of the core API.
83
+ export const childRelation = (direction: RelationDirection = 'outbound'): Relation => relation('child', direction);
84
+ export const actionRelation = (direction: RelationDirection = 'outbound'): Relation => relation('action', direction);
52
85
 
53
86
  export const isGraphNode = (data: unknown): data is Node =>
54
87
  data && typeof data === 'object' && 'id' in data && 'properties' in data && data.properties
@@ -63,30 +96,45 @@ export type NodeArg<TData, TProperties extends Record<string, any> = Record<stri
63
96
  nodes?: NodeArg<unknown>[];
64
97
 
65
98
  /** Will automatically add specified edges. */
66
- edges?: [string, Relation][];
99
+ edges?: [string, RelationInput][];
67
100
  };
68
101
 
69
102
  //
70
103
  // Actions
71
104
  //
72
105
 
73
- export type InvokeParams = {
106
+ export type InvokeProps = {
74
107
  /** Node the invoked action is connected to. */
75
108
  parent?: Node;
76
109
 
110
+ /** Path from root to the node in the current tree context. */
111
+ path?: string[];
112
+
77
113
  caller?: string;
78
114
  };
79
115
 
80
- export type ActionData = (params?: InvokeParams) => MaybePromise<void>;
116
+ /**
117
+ * Action data is an Effect-returning function.
118
+ * The Effect is provided with captured context at execution time.
119
+ */
120
+ export type ActionData<R = never> = (params?: InvokeProps) => Effect.Effect<any, Error, R>;
121
+
122
+ /**
123
+ * Context captured at extension creation time.
124
+ * Automatically provided to action Effects at execution.
125
+ */
126
+ export type ActionContext = Context.Context<any>;
81
127
 
82
128
  export type Action<TProperties extends Record<string, any> = Record<string, any>> = Readonly<
83
129
  Omit<Node<ActionData, TProperties>, 'properties'> & {
84
130
  properties: Readonly<TProperties>;
131
+ /** Captured context from extension creation. Provided automatically at action execution. */
132
+ _actionContext?: ActionContext;
85
133
  }
86
134
  >;
87
135
 
88
136
  export const isAction = (data: unknown): data is Action =>
89
- isGraphNode(data) ? typeof data.data === 'function' && data.type === ACTION_TYPE : false;
137
+ isGraphNode(data) ? typeof data.data === 'function' && data.type === ActionType : false;
90
138
 
91
139
  export const actionGroupSymbol = Symbol('ActionGroup');
92
140
 
@@ -97,8 +145,34 @@ export type ActionGroup<TProperties extends Record<string, any> = Record<string,
97
145
  >;
98
146
 
99
147
  export const isActionGroup = (data: unknown): data is ActionGroup =>
100
- isGraphNode(data) ? data.data === actionGroupSymbol && data.type === ACTION_GROUP_TYPE : false;
148
+ isGraphNode(data) ? data.data === actionGroupSymbol && data.type === ActionGroupType : false;
101
149
 
102
150
  export type ActionLike = Action | ActionGroup;
103
151
 
104
152
  export const isActionLike = (data: unknown): data is Action | ActionGroup => isAction(data) || isActionGroup(data);
153
+
154
+ //
155
+ // Node Factories
156
+ //
157
+
158
+ /** Typed factory for constructing a NodeArg. Provides auto-complete and type validation. */
159
+ export const make = <TData = any, TProperties extends Record<string, any> = Record<string, any>>(
160
+ arg: NodeArg<TData, TProperties>,
161
+ ): NodeArg<TData, TProperties> => arg;
162
+
163
+ /** Create an action node. Automatically sets `type: ActionType`. */
164
+ export const makeAction = <R = never>(
165
+ arg: Omit<NodeArg<ActionData<R>>, 'type' | 'nodes' | 'edges'>,
166
+ ): NodeArg<ActionData<R>> => ({
167
+ ...arg,
168
+ type: ActionType,
169
+ });
170
+
171
+ /** Create an action group node. Automatically sets `type` and `data`. */
172
+ export const makeActionGroup = (
173
+ arg: Omit<NodeArg<typeof actionGroupSymbol>, 'type' | 'data' | 'nodes' | 'edges'>,
174
+ ): NodeArg<typeof actionGroupSymbol> => ({
175
+ ...arg,
176
+ type: ActionGroupType,
177
+ data: actionGroupSymbol,
178
+ });
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export { scheduleTask, yieldOrContinue } from 'main-thread-scheduling';
@@ -0,0 +1,17 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // CF Workers / Node fallback: there is no main thread to yield to.
6
+ // scheduleTask runs the callback on the next microtask; yieldOrContinue is a microtask hop.
7
+
8
+ type ScheduleOptions = { strategy?: 'smooth' | 'interactive' | 'idle'; signal?: AbortSignal };
9
+
10
+ export const scheduleTask = async <T>(callback: () => T | Promise<T>, _options?: ScheduleOptions): Promise<T> => {
11
+ await Promise.resolve();
12
+ return callback();
13
+ };
14
+
15
+ export const yieldOrContinue = async (_priority: 'smooth' | 'interactive' | 'idle'): Promise<void> => {
16
+ await Promise.resolve();
17
+ };
@@ -2,39 +2,30 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import '@dxos-theme';
6
-
7
- import { type Registry, RegistryContext, Rx, useRxValue } from '@effect-rx/rx-react';
8
- import { type Meta } from '@storybook/react-vite';
9
- import { Option, pipe } from 'effect';
10
- import React, { type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useState } from 'react';
11
-
12
- import {
13
- live,
14
- isSpace,
15
- Query,
16
- type QueryResult,
17
- type Space,
18
- SpaceState,
19
- Expando,
20
- type Live,
21
- Filter,
22
- } from '@dxos/client/echo';
23
- import { Obj, Type } from '@dxos/echo';
24
- import { faker } from '@dxos/random';
5
+ import { Atom, type Registry, RegistryContext, useAtomValue } from '@effect-atom/atom-react';
6
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
7
+ import * as Function from 'effect/Function';
8
+ import * as Option from 'effect/Option';
9
+ import React, { type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
10
+
11
+ import { type Space, SpaceState, isSpace } from '@dxos/client/echo';
12
+ import { Filter, Obj, Query } from '@dxos/echo';
13
+ import { AtomObj, AtomQuery } from '@dxos/echo-atom';
14
+ import { TestSchema } from '@dxos/echo/testing';
15
+ import { random } from '@dxos/random';
25
16
  import { type Client, useClient } from '@dxos/react-client';
26
17
  import { withClientProvider } from '@dxos/react-client/testing';
27
- import { Input, Select, Icon, IconButton } from '@dxos/react-ui';
28
- import { Path, Tree } from '@dxos/react-ui-list';
29
- import { getSize, mx } from '@dxos/react-ui-theme';
30
- import { withTheme } from '@dxos/storybook-utils';
31
- import { byPosition, isNonNullable, safeParseInt } from '@dxos/util';
32
-
18
+ import { Icon, IconButton, Input, Select } from '@dxos/react-ui';
19
+ import { Path, Tree, type TreeModel } from '@dxos/react-ui-list';
20
+ import { withTheme } from '@dxos/react-ui/testing';
21
+ import { getSize, mx } from '@dxos/ui-theme';
22
+ import { safeParseInt } from '@dxos/util';
23
+
24
+ import * as CreateAtom from '../atoms';
25
+ import * as Graph from '../graph';
26
+ import * as GraphBuilder from '../graph-builder';
27
+ import * as Node from '../node';
33
28
  import { JsonTree } from './Tree';
34
- import { type ExpandableGraph, ROOT_ID } from '../graph';
35
- import { GraphBuilder, createExtension, rxFromObservable, rxFromSignal } from '../graph-builder';
36
- import { type Node } from '../node';
37
- import { rxFromQuery } from '../testing';
38
29
 
39
30
  const DEFAULT_PERIOD = 500;
40
31
 
@@ -56,46 +47,47 @@ const actionWeights = {
56
47
  [Action.RENAME_OBJECT]: 4,
57
48
  };
58
49
 
59
- const createGraph = (client: Client, registry: Registry.Registry): ExpandableGraph => {
60
- const spaceBuilderExtension = createExtension({
50
+ const createGraph = (client: Client, registry: Registry.Registry): Graph.ExpandableGraph => {
51
+ const spaceBuilderExtension = GraphBuilder.createExtensionRaw({
61
52
  id: 'space',
62
53
  connector: (node) =>
63
- Rx.make((get) =>
64
- pipe(
54
+ Atom.make((get) =>
55
+ Function.pipe(
65
56
  get(node),
66
- Option.flatMap((node) => (node.id === ROOT_ID ? Option.some(node) : Option.none())),
57
+ Option.flatMap((node) => (node.id === Node.RootId ? Option.some(node) : Option.none())),
67
58
  Option.map(() => {
68
- const spaces = get(rxFromObservable(client.spaces)) ?? [];
59
+ const spaces = get(CreateAtom.fromObservable(client.spaces)) ?? [];
69
60
  return spaces
70
- .filter((space) => get(rxFromObservable(space.state)) === SpaceState.SPACE_READY)
71
- .map((space) => ({
72
- id: space.id,
73
- type: 'dxos.org/type/Space',
74
- properties: { label: get(rxFromSignal(() => space.properties.name)) },
75
- data: space,
76
- }));
61
+ .filter((space: any) => get(CreateAtom.fromObservable(space.state)) === SpaceState.SPACE_READY)
62
+ .map((space) => {
63
+ const propertiesSnapshot = get(AtomObj.make(space.properties));
64
+ return {
65
+ id: space.id,
66
+ type: 'org.dxos.type.space',
67
+ properties: {
68
+ label: propertiesSnapshot.name,
69
+ },
70
+ data: space,
71
+ };
72
+ });
77
73
  }),
78
74
  Option.getOrElse(() => []),
79
75
  ),
80
76
  ),
81
77
  });
82
78
 
83
- const objectBuilderExtension = createExtension({
79
+ const objectBuilderExtension = GraphBuilder.createExtensionRaw({
84
80
  id: 'object',
85
81
  connector: (node) => {
86
- let query: QueryResult<Live<Expando>> | undefined;
87
- return Rx.make((get) =>
88
- pipe(
82
+ return Atom.make((get) =>
83
+ Function.pipe(
89
84
  get(node),
90
85
  Option.flatMap((node) => (isSpace(node.data) ? Option.some(node.data) : Option.none())),
91
86
  Option.map((space) => {
92
- if (!query) {
93
- query = space.db.query(Query.type(Expando, { type: 'test' }));
94
- }
95
- const objects = get(rxFromQuery(query));
87
+ const objects = get(AtomQuery.make(space.db, Query.type(TestSchema.Expando, { type: 'test' })));
96
88
  return objects.map((object) => ({
97
89
  id: object.id,
98
- type: 'dxos.org/type/test',
90
+ type: 'org.dxos.type.test',
99
91
  properties: { label: object.name },
100
92
  data: object,
101
93
  }));
@@ -106,15 +98,15 @@ const createGraph = (client: Client, registry: Registry.Registry): ExpandableGra
106
98
  },
107
99
  });
108
100
 
109
- const graph = new GraphBuilder({ registry })
110
- .addExtension(spaceBuilderExtension)
111
- .addExtension(objectBuilderExtension).graph;
101
+ const builder = GraphBuilder.make({ registry });
102
+ GraphBuilder.addExtension(builder, spaceBuilderExtension);
103
+ GraphBuilder.addExtension(builder, objectBuilderExtension);
104
+ const graph = builder.graph;
112
105
  graph.onNodeChanged.on(({ id }) => {
113
- graph.expand(id);
106
+ Graph.expand(graph, id, 'child');
114
107
  });
115
- graph.expand(ROOT_ID);
108
+ Graph.expand(graph, Node.RootId, 'child');
116
109
  (window as any).graph = graph;
117
-
118
110
  return graph;
119
111
  };
120
112
 
@@ -134,9 +126,9 @@ const getRandomSpace = (client: Client): Space | undefined => {
134
126
  const getSpaceWithObjects = async (client: Client): Promise<Space | undefined> => {
135
127
  const readySpaces = client.spaces.get().filter((space) => space.state.get() === SpaceState.SPACE_READY);
136
128
  const spaceQueries = await Promise.all(
137
- readySpaces.map((space) => space.db.query(Filter.type(Expando, { type: 'test' })).run()),
129
+ readySpaces.map((space) => space.db.query(Filter.type(TestSchema.Expando, { type: 'test' })).run()),
138
130
  );
139
- const spaces = readySpaces.filter((space, index) => spaceQueries[index].objects.length > 0);
131
+ const spaces = readySpaces.filter((space, index) => spaceQueries[index].length > 0);
140
132
  return spaces[Math.floor(Math.random() * spaces.length)];
141
133
  };
142
134
 
@@ -153,19 +145,26 @@ const runAction = async (client: Client, action: Action) => {
153
145
  case Action.RENAME_SPACE: {
154
146
  const space = getRandomSpace(client);
155
147
  if (space) {
156
- space.properties.name = faker.commerce.productName();
148
+ Obj.update(space.properties, (obj) => {
149
+ obj.name = random.commerce.productName();
150
+ });
157
151
  }
158
152
  break;
159
153
  }
160
154
 
161
155
  case Action.ADD_OBJECT:
162
- getRandomSpace(client)?.db.add(Obj.make(Type.Expando, { type: 'test', name: faker.commerce.productName() }));
156
+ getRandomSpace(client)?.db.add(
157
+ Obj.make(TestSchema.Expando, {
158
+ type: 'test',
159
+ name: random.commerce.productName(),
160
+ }),
161
+ );
163
162
  break;
164
163
 
165
164
  case Action.REMOVE_OBJECT: {
166
165
  const space = await getSpaceWithObjects(client);
167
166
  if (space) {
168
- const { objects } = await space.db.query(Filter.type(Expando, { type: 'test' })).run();
167
+ const objects = await space.db.query(Filter.type(TestSchema.Expando, { type: 'test' })).run();
169
168
  space.db.remove(objects[Math.floor(Math.random() * objects.length)]);
170
169
  }
171
170
  break;
@@ -174,8 +173,11 @@ const runAction = async (client: Client, action: Action) => {
174
173
  case Action.RENAME_OBJECT: {
175
174
  const space = await getSpaceWithObjects(client);
176
175
  if (space) {
177
- const { objects } = await space.db.query(Filter.type(Expando, { type: 'test' })).run();
178
- objects[Math.floor(Math.random() * objects.length)].name = faker.commerce.productName();
176
+ const objects = await space.db.query(Filter.type(TestSchema.Expando, { type: 'test' })).run();
177
+ const object = objects[Math.floor(Math.random() * objects.length)];
178
+ Obj.update(object, (object) => {
179
+ object.name = random.commerce.productName();
180
+ });
179
181
  }
180
182
  break;
181
183
  }
@@ -213,14 +215,13 @@ const Controls = ({ children }: PropsWithChildren) => {
213
215
  <Input.Root>
214
216
  <Input.TextInput
215
217
  autoComplete='off'
216
- size={5}
217
- classNames='w-[100px] text-right pie-[22px]'
218
+ classNames='w-[100px] text-right pe-[22px]'
218
219
  placeholder='Interval'
219
220
  value={actionInterval}
220
221
  onChange={({ target: { value } }) => setActionInterval(value)}
221
222
  />
222
223
  </Input.Root>
223
- <Icon icon='ph--timer--regular' classNames={mx('absolute inline-end-1 block-start-1 mt-[6px]', getSize(3))} />
224
+ <Icon icon='ph--timer--regular' classNames={mx('absolute right-1 top-1 mt-[6px]', getSize(3))} />
224
225
  </div>
225
226
  <IconButton icon='ph--plus--regular' label='Add' onClick={() => action && runAction(client, action)} />
226
227
  <Select.Root value={action?.toString()} onValueChange={(action) => setAction(action as unknown as Action)}>
@@ -246,28 +247,31 @@ const Controls = ({ children }: PropsWithChildren) => {
246
247
  );
247
248
  };
248
249
 
249
- const meta: Meta = {
250
+ const meta = {
250
251
  title: 'sdk/app-graph/EchoGraph',
251
252
  decorators: [
253
+ withTheme(),
252
254
  withClientProvider({
253
255
  createIdentity: true,
254
- onIdentityCreated: async ({ client }) => {
256
+ types: [TestSchema.Expando],
257
+ onCreateIdentity: async ({ client }) => {
255
258
  await client.spaces.create();
256
259
  await client.spaces.create();
257
260
  },
258
261
  }),
259
- withTheme,
260
262
  ],
261
- };
263
+ } satisfies Meta;
262
264
 
263
265
  export default meta;
264
266
 
265
- export const JsonView = {
267
+ type Story = StoryObj<typeof meta>;
268
+
269
+ export const JsonView: Story = {
266
270
  render: () => {
267
271
  const client = useClient();
268
272
  const registry = useContext(RegistryContext);
269
273
  const graph = useMemo(() => createGraph(client, registry), [client, registry]);
270
- const data = useRxValue(graph.json());
274
+ const data = useAtomValue(graph.json());
271
275
 
272
276
  return (
273
277
  <>
@@ -278,99 +282,134 @@ export const JsonView = {
278
282
  },
279
283
  };
280
284
 
281
- export const TreeView = {
285
+ export const TreeView: Story = {
282
286
  render: () => {
283
287
  const client = useClient();
284
288
  const registry = useContext(RegistryContext);
285
289
  const graph = useMemo(() => createGraph(client, registry), [client, registry]);
286
- const state = useMemo(() => new Map<string, Live<{ open: boolean; current: boolean }>>(), []);
287
-
288
- const useItems = useCallback(
289
- (node?: Node, options?: { disposition?: string; sort?: boolean }) => {
290
- const connections = useRxValue(graph.connections(node?.id ?? ROOT_ID));
291
- return options?.sort ? connections.toSorted((a, b) => byPosition(a.properties, b.properties)) : connections;
290
+ const stateRef = useRef(new Map<string, Atom.Writable<{ open: boolean; current: boolean }>>());
291
+
292
+ const getOrCreateState = useMemo(
293
+ () => (pathKey: string) => {
294
+ let atom = stateRef.current.get(pathKey);
295
+ if (!atom) {
296
+ atom = Atom.make({ open: true, current: false }).pipe(Atom.keepAlive);
297
+ stateRef.current.set(pathKey, atom);
298
+ }
299
+ return atom;
292
300
  },
301
+ [],
302
+ );
303
+
304
+ const childIdsFamily = useMemo(
305
+ () =>
306
+ Atom.family((id: string) =>
307
+ Atom.make((get) => {
308
+ const connections = get(graph.connections(id, 'child'));
309
+ return connections.map((connection) => connection.id);
310
+ }),
311
+ ),
293
312
  [graph],
294
313
  );
295
314
 
296
- const getProps = useCallback(
297
- (node: Node, path: string[]) => {
298
- const children = graph
299
- .getConnections(node.id, 'outbound')
300
- .map((n) => {
301
- // Break cycles.
302
- const nextPath = [...path, node.id];
303
- return nextPath.includes(n.id) ? undefined : (n as Node);
304
- })
305
- .filter(isNonNullable) as Node[];
306
- const parentOf =
307
- children.length > 0 ? children.map(({ id }) => id) : node.properties.role === 'branch' ? [] : undefined;
308
- return {
309
- id: node.id,
310
- label: node.id,
311
- icon: node.type === 'dxos.org/type/Space' ? 'ph--planet--regular' : 'ph--placeholder--regular',
312
- parentOf,
313
- };
314
- },
315
+ const itemFamily = useMemo(
316
+ () =>
317
+ Atom.family((id: string) =>
318
+ Atom.make((get) => {
319
+ const node = get(graph.node(id));
320
+ return Option.isSome(node) ? node.value : undefined;
321
+ }),
322
+ ),
315
323
  [graph],
316
324
  );
317
325
 
318
- const isOpen = useCallback(
319
- (_path: string[]) => {
320
- const path = Path.create(..._path);
321
- const object = state.get(path) ?? live({ open: true, current: false });
322
- if (!state.has(path)) {
323
- state.set(path, object);
324
- }
326
+ const itemPropsFamily = useMemo(
327
+ () =>
328
+ Atom.family((pathKey: string) => {
329
+ const path = pathKey.split('~');
330
+ const id = path[path.length - 1];
331
+ return Atom.make((get) => {
332
+ const nodeOpt = get(graph.node(id));
333
+ const node = Option.isSome(nodeOpt) ? nodeOpt.value : undefined;
334
+ if (!node) {
335
+ return { id, label: id };
336
+ }
337
+ const connections = get(graph.connections(node.id, 'child'));
338
+ const safeChildren = connections.filter((n) => !path.includes(n.id));
339
+ const parentOf =
340
+ safeChildren.length > 0
341
+ ? safeChildren.map(({ id }) => id)
342
+ : node.properties.role === 'branch'
343
+ ? []
344
+ : undefined;
345
+ return {
346
+ id: node.id,
347
+ label: node.id,
348
+ icon: node.type === 'org.dxos.type.space' ? 'ph--planet--regular' : 'ph--placeholder--regular',
349
+ parentOf,
350
+ };
351
+ });
352
+ }),
353
+ [graph],
354
+ );
325
355
 
326
- return object.open;
327
- },
328
- [state],
356
+ const itemOpenFamily = useMemo(
357
+ () =>
358
+ Atom.family((pathKey: string) => {
359
+ const stateAtom = getOrCreateState(pathKey);
360
+ return Atom.make((get) => get(stateAtom).open);
361
+ }),
362
+ [getOrCreateState],
329
363
  );
330
364
 
331
- const isCurrent = useCallback(
332
- (_path: string[]) => {
333
- const path = Path.create(..._path);
334
- const object = state.get(path) ?? live({ open: false, current: false });
335
- if (!state.has(path)) {
336
- state.set(path, object);
337
- }
365
+ const itemCurrentFamily = useMemo(
366
+ () =>
367
+ Atom.family((pathKey: string) => {
368
+ const stateAtom = getOrCreateState(pathKey);
369
+ return Atom.make((get) => get(stateAtom).current);
370
+ }),
371
+ [getOrCreateState],
372
+ );
338
373
 
339
- return object.current;
340
- },
341
- [state],
374
+ const model: TreeModel<Node.Node> = useMemo(
375
+ () => ({
376
+ childIds: (parentId?: string) => childIdsFamily(parentId ?? Node.RootId),
377
+ item: (id: string) => itemFamily(id),
378
+ itemProps: (path: string[]) => itemPropsFamily(path.join('~')),
379
+ itemOpen: (path: string[]) => itemOpenFamily(Path.create(...path)),
380
+ itemCurrent: (path: string[]) => itemCurrentFamily(Path.create(...path)),
381
+ }),
382
+ [childIdsFamily, itemFamily, itemPropsFamily, itemOpenFamily, itemCurrentFamily],
342
383
  );
343
384
 
344
385
  const onOpenChange = useCallback(
345
386
  ({ path: _path, open }: { path: string[]; open: boolean }) => {
346
387
  const path = Path.create(..._path);
347
- const object = state.get(path);
348
- object!.open = open;
388
+ const atom = stateRef.current.get(path);
389
+ if (atom) {
390
+ const prev = registry.get(atom);
391
+ registry.set(atom, { ...prev, open });
392
+ }
349
393
  },
350
- [state],
394
+ [registry],
351
395
  );
352
396
 
353
397
  const onSelect = useCallback(
354
398
  ({ path: _path, current }: { path: string[]; current: boolean }) => {
355
399
  const path = Path.create(..._path);
356
- const object = state.get(path);
357
- object!.current = current;
400
+ const atom = stateRef.current.get(path);
401
+ if (atom) {
402
+ const prev = registry.get(atom);
403
+ registry.set(atom, { ...prev, current });
404
+ }
358
405
  },
359
- [state],
406
+ [registry],
360
407
  );
361
408
 
362
409
  return (
363
410
  <>
364
411
  <Controls />
365
- <Tree
366
- id={ROOT_ID}
367
- useItems={useItems}
368
- getProps={getProps}
369
- isOpen={isOpen}
370
- isCurrent={isCurrent}
371
- onOpenChange={onOpenChange}
372
- onSelect={onSelect}
373
- />
412
+ <Tree model={model} id={Node.RootId} onOpenChange={onOpenChange} onSelect={onSelect} />
374
413
  </>
375
414
  );
376
415
  },
@@ -4,7 +4,7 @@
4
4
 
5
5
  import React, { type FC, type HTMLAttributes, useState } from 'react';
6
6
 
7
- import { mx } from '@dxos/react-ui-theme';
7
+ import { mx } from '@dxos/ui-theme';
8
8
 
9
9
  // TODO(burdon): Copied form devtools.
10
10
 
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './setup-graph-builder';