@astrale-os/shell-react 0.2.2 → 0.2.4

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.
@@ -12,8 +12,10 @@ export function useOut(node, edge, opts) {
12
12
  export function useIn(node, edge, opts) {
13
13
  return useEndpoint(node, edge, 'in', opts);
14
14
  }
15
- function useEndpoint(node, edge, side, _opts) {
15
+ function useEndpoint(node, edge, side, opts) {
16
16
  const schema = isIdleInput(node) ? undefined : schemaOf(node);
17
+ const single = schema !== undefined && isSingleEndpoint(schema, edge, side);
18
+ const key = `${node?.id ?? ''}\u0000${edge}\u0000${side}`;
17
19
  const run = useCallback(async () => {
18
20
  if (isIdleInput(node))
19
21
  return null;
@@ -23,22 +25,26 @@ function useEndpoint(node, edge, side, _opts) {
23
25
  return result;
24
26
  // eslint-disable-next-line react-hooks/exhaustive-deps
25
27
  }, [node?.id, edge, side]);
26
- const { data, pending, error, refetch } = useAsyncRead(isIdleInput(node) ? null : run, [
27
- node?.id ?? null,
28
- edge,
29
- side,
30
- ]);
28
+ const { data, pending, error, refetch } = useAsyncRead(isIdleInput(node) ? null : run, key, opts?.keepPrevious === true);
31
29
  return useMemo(() => {
32
30
  const live = { pending, error, live: false, seq: 0, refetch };
33
31
  if (isIdleInput(node))
34
32
  return { ...live, node: null, nodes: EMPTY };
35
- if (Array.isArray(data))
36
- return { ...live, nodes: data };
37
- return { ...live, node: (data ?? null) };
38
- }, [node, data, pending, error, refetch]);
33
+ if (single) {
34
+ const value = Array.isArray(data) ? data[0] : data;
35
+ return { ...live, node: (value ?? null) };
36
+ }
37
+ const values = Array.isArray(data) ? data : data === null || data === undefined ? EMPTY : [data];
38
+ return { ...live, nodes: values };
39
+ }, [node, data, pending, error, refetch, single]);
39
40
  }
40
41
  // ── helpers ──────────────────────────────────────────────────────────────────
41
42
  const EMPTY = Object.freeze([]);
43
+ function isSingleEndpoint(schema, edge, side) {
44
+ const definition = schema.classes[edge];
45
+ const cardinality = (side === 'out' ? definition?.to : definition?.from)?.cardinality;
46
+ return cardinality === '1' || cardinality === '0..1';
47
+ }
42
48
  function tagEndpoints(result, schema) {
43
49
  if (Array.isArray(result))
44
50
  for (const r of result)
@@ -47,30 +53,52 @@ function tagEndpoints(result, schema) {
47
53
  tagSchema(result, schema);
48
54
  }
49
55
  /** One-shot async read with pending/error/refetch and stale-response guarding. */
50
- function useAsyncRead(run, deps) {
51
- const [state, setState] = useState({ pending: run !== null, error: undefined });
56
+ function useAsyncRead(run, key, keepPrevious) {
57
+ const [state, setState] = useState({
58
+ pending: run !== null,
59
+ error: undefined,
60
+ key: run === null ? null : key,
61
+ });
52
62
  const runRef = useRef(run);
53
63
  runRef.current = run;
64
+ const keyRef = useRef(key);
65
+ keyRef.current = key;
66
+ const keepPreviousRef = useRef(keepPrevious);
67
+ keepPreviousRef.current = keepPrevious;
54
68
  const seq = useRef(0);
55
69
  const refetch = useCallback(() => {
56
70
  const current = runRef.current;
71
+ const currentKey = keyRef.current;
57
72
  if (current === null) {
58
- setState({ pending: false, error: undefined, data: undefined });
73
+ setState({ pending: false, error: undefined, data: undefined, key: null });
59
74
  return Promise.resolve();
60
75
  }
61
76
  const my = ++seq.current;
62
- setState((s) => ({ ...s, pending: true, error: undefined }));
77
+ setState((s) => ({
78
+ data: s.key === currentKey || keepPreviousRef.current === true ? s.data : undefined,
79
+ pending: true,
80
+ error: undefined,
81
+ key: currentKey,
82
+ }));
63
83
  return current().then((data) => {
64
- if (my === seq.current)
65
- setState({ data, pending: false, error: undefined });
84
+ if (my === seq.current) {
85
+ setState({ data, pending: false, error: undefined, key: currentKey });
86
+ }
66
87
  }, (error) => {
67
- if (my === seq.current)
68
- setState((s) => ({ data: s.data, pending: false, error }));
88
+ if (my === seq.current) {
89
+ setState((s) => ({ data: s.data, pending: false, error, key: currentKey }));
90
+ }
69
91
  });
70
92
  }, []);
71
93
  useEffect(() => {
72
94
  void refetch();
73
- // eslint-disable-next-line react-hooks/exhaustive-deps
74
- }, deps);
75
- return { ...state, refetch };
95
+ }, [key, refetch]);
96
+ const current = state.key === key;
97
+ return {
98
+ data: current || keepPrevious ? state.data : undefined,
99
+ pending: run !== null && (!current || state.pending),
100
+ error: current ? state.error : undefined,
101
+ key: current ? state.key : null,
102
+ refetch,
103
+ };
76
104
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/shell-react",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Astrale shell-react — the React face of the shell (provider, hooks, view components)",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -26,8 +26,8 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@astrale-domains/shell-schema": ">=0.3.2 <1.0.0",
29
- "@astrale-os/kernel-client": ">=0.4.5 <1.0.0",
30
- "@astrale-os/kernel-core": ">=0.7.4 <1.0.0",
29
+ "@astrale-os/kernel-client": ">=0.4.6 <1.0.0",
30
+ "@astrale-os/kernel-core": ">=0.7.5 <1.0.0",
31
31
  "@astrale-os/kernel-dsl": ">=0.1.4 <1.0.0",
32
32
  "jose": "^6.2.3",
33
33
  "zod": "^4.3.6"
@@ -44,7 +44,7 @@
44
44
  "react-dom": "^19.2.0",
45
45
  "typescript": "~6.0.1-rc",
46
46
  "vitest": "^3.2.4",
47
- "@astrale-os/shell": "0.3.2"
47
+ "@astrale-os/shell": "0.3.3"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "@astrale-os/shell": ">=0.2.0 <1.0.0",
@@ -39,9 +39,11 @@ function useEndpoint<S extends Schema>(
39
39
  node: AnyBoundNode<S> | null | undefined,
40
40
  edge: EdgeNames<S>,
41
41
  side: 'out' | 'in',
42
- _opts?: ReadOptions,
42
+ opts?: ReadOptions,
43
43
  ): EndpointEnvelope<S> {
44
44
  const schema = isIdleInput(node) ? undefined : schemaOf(node)
45
+ const single = schema !== undefined && isSingleEndpoint(schema, edge, side)
46
+ const key = `${node?.id ?? ''}\u0000${edge}\u0000${side}`
45
47
  const run = useCallback(async (): Promise<unknown> => {
46
48
  if (isIdleInput(node)) return null
47
49
  const result = await (side === 'out' ? node.links.out(edge) : node.links.in(edge))
@@ -50,24 +52,39 @@ function useEndpoint<S extends Schema>(
50
52
  // eslint-disable-next-line react-hooks/exhaustive-deps
51
53
  }, [node?.id, edge, side])
52
54
 
53
- const { data, pending, error, refetch } = useAsyncRead(isIdleInput(node) ? null : run, [
54
- node?.id ?? null,
55
- edge,
56
- side,
57
- ])
55
+ const { data, pending, error, refetch } = useAsyncRead(
56
+ isIdleInput(node) ? null : run,
57
+ key,
58
+ opts?.keepPrevious === true,
59
+ )
58
60
 
59
61
  return useMemo<EndpointEnvelope<S>>(() => {
60
62
  const live: ReadState = { pending, error, live: false, seq: 0, refetch }
61
63
  if (isIdleInput(node)) return { ...live, node: null, nodes: EMPTY }
62
- if (Array.isArray(data)) return { ...live, nodes: data as readonly AnyBoundNode<S>[] }
63
- return { ...live, node: (data ?? null) as AnyBoundNode<S> | null }
64
- }, [node, data, pending, error, refetch])
64
+ if (single) {
65
+ const value = Array.isArray(data) ? data[0] : data
66
+ return { ...live, node: (value ?? null) as AnyBoundNode<S> | null }
67
+ }
68
+ const values = Array.isArray(data) ? data : data === null || data === undefined ? EMPTY : [data]
69
+ return { ...live, nodes: values as readonly AnyBoundNode<S>[] }
70
+ }, [node, data, pending, error, refetch, single])
65
71
  }
66
72
 
67
73
  // ── helpers ──────────────────────────────────────────────────────────────────
68
74
 
69
75
  const EMPTY: readonly never[] = Object.freeze([])
70
76
 
77
+ function isSingleEndpoint(schema: Schema, edge: string, side: 'out' | 'in'): boolean {
78
+ const definition = schema.classes[edge] as
79
+ | {
80
+ readonly from?: { readonly cardinality?: string }
81
+ readonly to?: { readonly cardinality?: string }
82
+ }
83
+ | undefined
84
+ const cardinality = (side === 'out' ? definition?.to : definition?.from)?.cardinality
85
+ return cardinality === '1' || cardinality === '0..1'
86
+ }
87
+
71
88
  function tagEndpoints(result: unknown, schema: Schema): void {
72
89
  if (Array.isArray(result)) for (const r of result) tagSchema(r, schema)
73
90
  else if (result !== null && result !== undefined) tagSchema(result, schema)
@@ -77,40 +94,66 @@ interface AsyncState<T> {
77
94
  data?: T
78
95
  pending: boolean
79
96
  error: unknown
97
+ key: string | null
80
98
  }
81
99
 
82
100
  /** One-shot async read with pending/error/refetch and stale-response guarding. */
83
101
  function useAsyncRead<T>(
84
102
  run: (() => Promise<T>) | null,
85
- deps: readonly unknown[],
103
+ key: string,
104
+ keepPrevious: boolean,
86
105
  ): AsyncState<T> & { refetch(): Promise<void> } {
87
- const [state, setState] = useState<AsyncState<T>>({ pending: run !== null, error: undefined })
106
+ const [state, setState] = useState<AsyncState<T>>({
107
+ pending: run !== null,
108
+ error: undefined,
109
+ key: run === null ? null : key,
110
+ })
88
111
  const runRef = useRef(run)
89
112
  runRef.current = run
113
+ const keyRef = useRef(key)
114
+ keyRef.current = key
115
+ const keepPreviousRef = useRef(keepPrevious)
116
+ keepPreviousRef.current = keepPrevious
90
117
  const seq = useRef(0)
91
118
 
92
119
  const refetch = useCallback((): Promise<void> => {
93
120
  const current = runRef.current
121
+ const currentKey = keyRef.current
94
122
  if (current === null) {
95
- setState({ pending: false, error: undefined, data: undefined })
123
+ setState({ pending: false, error: undefined, data: undefined, key: null })
96
124
  return Promise.resolve()
97
125
  }
98
126
  const my = ++seq.current
99
- setState((s) => ({ ...s, pending: true, error: undefined }))
127
+ setState((s) => ({
128
+ data: s.key === currentKey || keepPreviousRef.current === true ? s.data : undefined,
129
+ pending: true,
130
+ error: undefined,
131
+ key: currentKey,
132
+ }))
100
133
  return current().then(
101
134
  (data) => {
102
- if (my === seq.current) setState({ data, pending: false, error: undefined })
135
+ if (my === seq.current) {
136
+ setState({ data, pending: false, error: undefined, key: currentKey })
137
+ }
103
138
  },
104
139
  (error: unknown) => {
105
- if (my === seq.current) setState((s) => ({ data: s.data, pending: false, error }))
140
+ if (my === seq.current) {
141
+ setState((s) => ({ data: s.data, pending: false, error, key: currentKey }))
142
+ }
106
143
  },
107
144
  )
108
145
  }, [])
109
146
 
110
147
  useEffect(() => {
111
148
  void refetch()
112
- // eslint-disable-next-line react-hooks/exhaustive-deps
113
- }, deps)
114
-
115
- return { ...state, refetch }
149
+ }, [key, refetch])
150
+
151
+ const current = state.key === key
152
+ return {
153
+ data: current || keepPrevious ? state.data : undefined,
154
+ pending: run !== null && (!current || state.pending),
155
+ error: current ? state.error : undefined,
156
+ key: current ? state.key : null,
157
+ refetch,
158
+ }
116
159
  }