@dxos/app-graph 0.8.4-main.fd6878d → 0.8.4-staging.60fe92afc8

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-7BYCDV55.mjs +1484 -0
  4. package/dist/lib/neutral/chunk-7BYCDV55.mjs.map +7 -0
  5. package/dist/lib/neutral/chunk-J5LGTIGS.mjs +10 -0
  6. package/dist/lib/neutral/chunk-J5LGTIGS.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 +112 -67
  17. package/dist/types/src/graph-builder.d.ts.map +1 -1
  18. package/dist/types/src/graph.d.ts +188 -222
  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 +243 -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 +52 -41
  42. package/src/atoms.ts +25 -0
  43. package/src/graph-builder.test.ts +1186 -147
  44. package/src/graph-builder.ts +752 -292
  45. package/src/graph.test.ts +451 -123
  46. package/src/graph.ts +1054 -403
  47. package/src/index.ts +10 -3
  48. package/src/node-matcher.test.ts +301 -0
  49. package/src/node-matcher.ts +313 -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 +172 -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 -831
  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 -833
  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 MakeOptional, type MaybePromise } 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,28 @@
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
- Expando,
14
- Filter,
15
- type Live,
16
- Query,
17
- type QueryResult,
18
- type Space,
19
- SpaceState,
20
- isSpace,
21
- live,
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 { TestSchema } from '@dxos/echo/testing';
14
+ import { random } from '@dxos/random';
25
15
  import { type Client, useClient } from '@dxos/react-client';
26
16
  import { withClientProvider } from '@dxos/react-client/testing';
27
17
  import { Icon, IconButton, Input, Select } 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
-
33
- import { type ExpandableGraph, ROOT_ID } from '../graph';
34
- import { GraphBuilder, createExtension, rxFromObservable, rxFromSignal } from '../graph-builder';
35
- import { type Node } from '../node';
36
- import { rxFromQuery } from '../testing';
37
-
18
+ import { Path, Tree, type TreeModel } from '@dxos/react-ui-list';
19
+ import { withTheme } from '@dxos/react-ui/testing';
20
+ import { getSize, mx } from '@dxos/ui-theme';
21
+ import { safeParseInt } from '@dxos/util';
22
+
23
+ import * as CreateAtom from '../atoms';
24
+ import * as Graph from '../graph';
25
+ import * as GraphBuilder from '../graph-builder';
26
+ import * as Node from '../node';
38
27
  import { JsonTree } from './Tree';
39
28
 
40
29
  const DEFAULT_PERIOD = 500;
@@ -57,46 +46,47 @@ const actionWeights = {
57
46
  [Action.RENAME_OBJECT]: 4,
58
47
  };
59
48
 
60
- const createGraph = (client: Client, registry: Registry.Registry): ExpandableGraph => {
61
- const spaceBuilderExtension = createExtension({
49
+ const createGraph = (client: Client, registry: Registry.Registry): Graph.ExpandableGraph => {
50
+ const spaceBuilderExtension = GraphBuilder.createExtensionRaw({
62
51
  id: 'space',
63
52
  connector: (node) =>
64
- Rx.make((get) =>
65
- pipe(
53
+ Atom.make((get) =>
54
+ Function.pipe(
66
55
  get(node),
67
- Option.flatMap((node) => (node.id === ROOT_ID ? Option.some(node) : Option.none())),
56
+ Option.flatMap((node) => (node.id === Node.RootId ? Option.some(node) : Option.none())),
68
57
  Option.map(() => {
69
- const spaces = get(rxFromObservable(client.spaces)) ?? [];
58
+ const spaces = get(CreateAtom.fromObservable(client.spaces)) ?? [];
70
59
  return spaces
71
- .filter((space) => get(rxFromObservable(space.state)) === SpaceState.SPACE_READY)
72
- .map((space) => ({
73
- id: space.id,
74
- type: 'dxos.org/type/Space',
75
- properties: { label: get(rxFromSignal(() => space.properties.name)) },
76
- data: space,
77
- }));
60
+ .filter((space: any) => get(CreateAtom.fromObservable(space.state)) === SpaceState.SPACE_READY)
61
+ .map((space) => {
62
+ const propertiesSnapshot = get(Obj.atom(space.properties));
63
+ return {
64
+ id: space.id,
65
+ type: 'org.dxos.type.space',
66
+ properties: {
67
+ label: propertiesSnapshot.name,
68
+ },
69
+ data: space,
70
+ };
71
+ });
78
72
  }),
79
73
  Option.getOrElse(() => []),
80
74
  ),
81
75
  ),
82
76
  });
83
77
 
84
- const objectBuilderExtension = createExtension({
78
+ const objectBuilderExtension = GraphBuilder.createExtensionRaw({
85
79
  id: 'object',
86
80
  connector: (node) => {
87
- let query: QueryResult<Live<Expando>> | undefined;
88
- return Rx.make((get) =>
89
- pipe(
81
+ return Atom.make((get) =>
82
+ Function.pipe(
90
83
  get(node),
91
84
  Option.flatMap((node) => (isSpace(node.data) ? Option.some(node.data) : Option.none())),
92
85
  Option.map((space) => {
93
- if (!query) {
94
- query = space.db.query(Query.type(Expando, { type: 'test' }));
95
- }
96
- const objects = get(rxFromQuery(query));
86
+ const objects = get(space.db.query(Query.type(TestSchema.Expando, { type: 'test' })).atom);
97
87
  return objects.map((object) => ({
98
88
  id: object.id,
99
- type: 'dxos.org/type/test',
89
+ type: 'org.dxos.type.test',
100
90
  properties: { label: object.name },
101
91
  data: object,
102
92
  }));
@@ -107,15 +97,15 @@ const createGraph = (client: Client, registry: Registry.Registry): ExpandableGra
107
97
  },
108
98
  });
109
99
 
110
- const graph = new GraphBuilder({ registry })
111
- .addExtension(spaceBuilderExtension)
112
- .addExtension(objectBuilderExtension).graph;
100
+ const builder = GraphBuilder.make({ registry });
101
+ GraphBuilder.addExtension(builder, spaceBuilderExtension);
102
+ GraphBuilder.addExtension(builder, objectBuilderExtension);
103
+ const graph = builder.graph;
113
104
  graph.onNodeChanged.on(({ id }) => {
114
- graph.expand(id);
105
+ Graph.expand(graph, id, 'child');
115
106
  });
116
- graph.expand(ROOT_ID);
107
+ Graph.expand(graph, Node.RootId, 'child');
117
108
  (window as any).graph = graph;
118
-
119
109
  return graph;
120
110
  };
121
111
 
@@ -135,9 +125,9 @@ const getRandomSpace = (client: Client): Space | undefined => {
135
125
  const getSpaceWithObjects = async (client: Client): Promise<Space | undefined> => {
136
126
  const readySpaces = client.spaces.get().filter((space) => space.state.get() === SpaceState.SPACE_READY);
137
127
  const spaceQueries = await Promise.all(
138
- readySpaces.map((space) => space.db.query(Filter.type(Expando, { type: 'test' })).run()),
128
+ readySpaces.map((space) => space.db.query(Filter.type(TestSchema.Expando, { type: 'test' })).run()),
139
129
  );
140
- const spaces = readySpaces.filter((space, index) => spaceQueries[index].objects.length > 0);
130
+ const spaces = readySpaces.filter((space, index) => spaceQueries[index].length > 0);
141
131
  return spaces[Math.floor(Math.random() * spaces.length)];
142
132
  };
143
133
 
@@ -154,19 +144,26 @@ const runAction = async (client: Client, action: Action) => {
154
144
  case Action.RENAME_SPACE: {
155
145
  const space = getRandomSpace(client);
156
146
  if (space) {
157
- space.properties.name = faker.commerce.productName();
147
+ Obj.update(space.properties, (obj) => {
148
+ obj.name = random.commerce.productName();
149
+ });
158
150
  }
159
151
  break;
160
152
  }
161
153
 
162
154
  case Action.ADD_OBJECT:
163
- getRandomSpace(client)?.db.add(Obj.make(Type.Expando, { type: 'test', name: faker.commerce.productName() }));
155
+ getRandomSpace(client)?.db.add(
156
+ Obj.make(TestSchema.Expando, {
157
+ type: 'test',
158
+ name: random.commerce.productName(),
159
+ }),
160
+ );
164
161
  break;
165
162
 
166
163
  case Action.REMOVE_OBJECT: {
167
164
  const space = await getSpaceWithObjects(client);
168
165
  if (space) {
169
- const { objects } = await space.db.query(Filter.type(Expando, { type: 'test' })).run();
166
+ const objects = await space.db.query(Filter.type(TestSchema.Expando, { type: 'test' })).run();
170
167
  space.db.remove(objects[Math.floor(Math.random() * objects.length)]);
171
168
  }
172
169
  break;
@@ -175,8 +172,11 @@ const runAction = async (client: Client, action: Action) => {
175
172
  case Action.RENAME_OBJECT: {
176
173
  const space = await getSpaceWithObjects(client);
177
174
  if (space) {
178
- const { objects } = await space.db.query(Filter.type(Expando, { type: 'test' })).run();
179
- objects[Math.floor(Math.random() * objects.length)].name = faker.commerce.productName();
175
+ const objects = await space.db.query(Filter.type(TestSchema.Expando, { type: 'test' })).run();
176
+ const object = objects[Math.floor(Math.random() * objects.length)];
177
+ Obj.update(object, (object) => {
178
+ object.name = random.commerce.productName();
179
+ });
180
180
  }
181
181
  break;
182
182
  }
@@ -214,14 +214,13 @@ const Controls = ({ children }: PropsWithChildren) => {
214
214
  <Input.Root>
215
215
  <Input.TextInput
216
216
  autoComplete='off'
217
- size={5}
218
- classNames='w-[100px] text-right pie-[22px]'
217
+ classNames='w-[100px] text-right pe-[22px]'
219
218
  placeholder='Interval'
220
219
  value={actionInterval}
221
220
  onChange={({ target: { value } }) => setActionInterval(value)}
222
221
  />
223
222
  </Input.Root>
224
- <Icon icon='ph--timer--regular' classNames={mx('absolute inline-end-1 block-start-1 mt-[6px]', getSize(3))} />
223
+ <Icon icon='ph--timer--regular' classNames={mx('absolute right-1 top-1 mt-[6px]', getSize(3))} />
225
224
  </div>
226
225
  <IconButton icon='ph--plus--regular' label='Add' onClick={() => action && runAction(client, action)} />
227
226
  <Select.Root value={action?.toString()} onValueChange={(action) => setAction(action as unknown as Action)}>
@@ -247,28 +246,31 @@ const Controls = ({ children }: PropsWithChildren) => {
247
246
  );
248
247
  };
249
248
 
250
- const meta: Meta = {
249
+ const meta = {
251
250
  title: 'sdk/app-graph/EchoGraph',
252
251
  decorators: [
252
+ withTheme(),
253
253
  withClientProvider({
254
254
  createIdentity: true,
255
- onIdentityCreated: async ({ client }) => {
255
+ types: [TestSchema.Expando],
256
+ onCreateIdentity: async ({ client }) => {
256
257
  await client.spaces.create();
257
258
  await client.spaces.create();
258
259
  },
259
260
  }),
260
- withTheme,
261
261
  ],
262
- };
262
+ } satisfies Meta;
263
263
 
264
264
  export default meta;
265
265
 
266
- export const JsonView = {
266
+ type Story = StoryObj<typeof meta>;
267
+
268
+ export const JsonView: Story = {
267
269
  render: () => {
268
270
  const client = useClient();
269
271
  const registry = useContext(RegistryContext);
270
272
  const graph = useMemo(() => createGraph(client, registry), [client, registry]);
271
- const data = useRxValue(graph.json());
273
+ const data = useAtomValue(graph.json());
272
274
 
273
275
  return (
274
276
  <>
@@ -279,99 +281,134 @@ export const JsonView = {
279
281
  },
280
282
  };
281
283
 
282
- export const TreeView = {
284
+ export const TreeView: Story = {
283
285
  render: () => {
284
286
  const client = useClient();
285
287
  const registry = useContext(RegistryContext);
286
288
  const graph = useMemo(() => createGraph(client, registry), [client, registry]);
287
- const state = useMemo(() => new Map<string, Live<{ open: boolean; current: boolean }>>(), []);
288
-
289
- const useItems = useCallback(
290
- (node?: Node, options?: { disposition?: string; sort?: boolean }) => {
291
- const connections = useRxValue(graph.connections(node?.id ?? ROOT_ID));
292
- return options?.sort ? connections.toSorted((a, b) => byPosition(a.properties, b.properties)) : connections;
289
+ const stateRef = useRef(new Map<string, Atom.Writable<{ open: boolean; current: boolean }>>());
290
+
291
+ const getOrCreateState = useMemo(
292
+ () => (pathKey: string) => {
293
+ let atom = stateRef.current.get(pathKey);
294
+ if (!atom) {
295
+ atom = Atom.make({ open: true, current: false }).pipe(Atom.keepAlive);
296
+ stateRef.current.set(pathKey, atom);
297
+ }
298
+ return atom;
293
299
  },
300
+ [],
301
+ );
302
+
303
+ const childIdsFamily = useMemo(
304
+ () =>
305
+ Atom.family((id: string) =>
306
+ Atom.make((get) => {
307
+ const connections = get(graph.connections(id, 'child'));
308
+ return connections.map((connection) => connection.id);
309
+ }),
310
+ ),
294
311
  [graph],
295
312
  );
296
313
 
297
- const getProps = useCallback(
298
- (node: Node, path: string[]) => {
299
- const children = graph
300
- .getConnections(node.id, 'outbound')
301
- .map((n) => {
302
- // Break cycles.
303
- const nextPath = [...path, node.id];
304
- return nextPath.includes(n.id) ? undefined : (n as Node);
305
- })
306
- .filter(isNonNullable) as Node[];
307
- const parentOf =
308
- children.length > 0 ? children.map(({ id }) => id) : node.properties.role === 'branch' ? [] : undefined;
309
- return {
310
- id: node.id,
311
- label: node.id,
312
- icon: node.type === 'dxos.org/type/Space' ? 'ph--planet--regular' : 'ph--placeholder--regular',
313
- parentOf,
314
- };
315
- },
314
+ const itemFamily = useMemo(
315
+ () =>
316
+ Atom.family((id: string) =>
317
+ Atom.make((get) => {
318
+ const node = get(graph.node(id));
319
+ return Option.isSome(node) ? node.value : undefined;
320
+ }),
321
+ ),
316
322
  [graph],
317
323
  );
318
324
 
319
- const isOpen = useCallback(
320
- (_path: string[]) => {
321
- const path = Path.create(..._path);
322
- const object = state.get(path) ?? live({ open: true, current: false });
323
- if (!state.has(path)) {
324
- state.set(path, object);
325
- }
325
+ const itemPropsFamily = useMemo(
326
+ () =>
327
+ Atom.family((pathKey: string) => {
328
+ const path = pathKey.split('~');
329
+ const id = path[path.length - 1];
330
+ return Atom.make((get) => {
331
+ const nodeOpt = get(graph.node(id));
332
+ const node = Option.isSome(nodeOpt) ? nodeOpt.value : undefined;
333
+ if (!node) {
334
+ return { id, label: id };
335
+ }
336
+ const connections = get(graph.connections(node.id, 'child'));
337
+ const safeChildren = connections.filter((n) => !path.includes(n.id));
338
+ const parentOf =
339
+ safeChildren.length > 0
340
+ ? safeChildren.map(({ id }) => id)
341
+ : node.properties.role === 'branch'
342
+ ? []
343
+ : undefined;
344
+ return {
345
+ id: node.id,
346
+ label: node.id,
347
+ icon: node.type === 'org.dxos.type.space' ? 'ph--planet--regular' : 'ph--circle-dashed--regular',
348
+ parentOf,
349
+ };
350
+ });
351
+ }),
352
+ [graph],
353
+ );
326
354
 
327
- return object.open;
328
- },
329
- [state],
355
+ const itemOpenFamily = useMemo(
356
+ () =>
357
+ Atom.family((pathKey: string) => {
358
+ const stateAtom = getOrCreateState(pathKey);
359
+ return Atom.make((get) => get(stateAtom).open);
360
+ }),
361
+ [getOrCreateState],
330
362
  );
331
363
 
332
- const isCurrent = useCallback(
333
- (_path: string[]) => {
334
- const path = Path.create(..._path);
335
- const object = state.get(path) ?? live({ open: false, current: false });
336
- if (!state.has(path)) {
337
- state.set(path, object);
338
- }
364
+ const itemCurrentFamily = useMemo(
365
+ () =>
366
+ Atom.family((pathKey: string) => {
367
+ const stateAtom = getOrCreateState(pathKey);
368
+ return Atom.make((get) => get(stateAtom).current);
369
+ }),
370
+ [getOrCreateState],
371
+ );
339
372
 
340
- return object.current;
341
- },
342
- [state],
373
+ const model: TreeModel<Node.Node> = useMemo(
374
+ () => ({
375
+ childIds: (parentId?: string) => childIdsFamily(parentId ?? Node.RootId),
376
+ item: (id: string) => itemFamily(id),
377
+ itemProps: (path: string[]) => itemPropsFamily(path.join('~')),
378
+ itemOpen: (path: string[]) => itemOpenFamily(Path.create(...path)),
379
+ itemCurrent: (path: string[]) => itemCurrentFamily(Path.create(...path)),
380
+ }),
381
+ [childIdsFamily, itemFamily, itemPropsFamily, itemOpenFamily, itemCurrentFamily],
343
382
  );
344
383
 
345
384
  const onOpenChange = useCallback(
346
385
  ({ path: _path, open }: { path: string[]; open: boolean }) => {
347
386
  const path = Path.create(..._path);
348
- const object = state.get(path);
349
- object!.open = open;
387
+ const atom = stateRef.current.get(path);
388
+ if (atom) {
389
+ const prev = registry.get(atom);
390
+ registry.set(atom, { ...prev, open });
391
+ }
350
392
  },
351
- [state],
393
+ [registry],
352
394
  );
353
395
 
354
396
  const onSelect = useCallback(
355
397
  ({ path: _path, current }: { path: string[]; current: boolean }) => {
356
398
  const path = Path.create(..._path);
357
- const object = state.get(path);
358
- object!.current = current;
399
+ const atom = stateRef.current.get(path);
400
+ if (atom) {
401
+ const prev = registry.get(atom);
402
+ registry.set(atom, { ...prev, current });
403
+ }
359
404
  },
360
- [state],
405
+ [registry],
361
406
  );
362
407
 
363
408
  return (
364
409
  <>
365
410
  <Controls />
366
- <Tree
367
- id={ROOT_ID}
368
- useItems={useItems}
369
- getProps={getProps}
370
- isOpen={isOpen}
371
- isCurrent={isCurrent}
372
- onOpenChange={onOpenChange}
373
- onSelect={onSelect}
374
- />
411
+ <Tree model={model} id={Node.RootId} onOpenChange={onOpenChange} onSelect={onSelect} />
375
412
  </>
376
413
  );
377
414
  },
@@ -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';