@novoagents/react 0.3.0-alpha.34 → 0.3.0-alpha.36

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0-alpha.36 - 2026-09-01
4
+
5
+ Companion release for `novoagents` 0.5.0-alpha.32.
6
+
7
+ - Controller ownership now rides the external-store snapshot (run key and
8
+ controller travel with the subscribed state), so an abandoned concurrent
9
+ render can no longer strand ownership in a render-written ref. Selector
10
+ readers isolate speculative renders while preserving equal object identity
11
+ across inline selector changes: the committed selection lives in a
12
+ `useState`-held cell that is still read during render, by design, mirroring
13
+ React's own `useSyncExternalStoreWithSelector` shim. Behavior is unchanged
14
+ across React 18 and 19, and the package now passes the canonical hooks lint
15
+ rules.
16
+
17
+ ## 0.3.0-alpha.35 - 2026-08-26
18
+
19
+ - Companion release for `novoagents` 0.5.0-alpha.31. GitHub connection and
20
+ agent repository grants now carry exact immutable repository identities
21
+ end to end; an explicit empty agent selection grants no repositories.
22
+
3
23
  ## 0.3.0-alpha.34 - 2026-08-26
4
24
 
5
25
  - Companion release for `novoagents` 0.5.0-alpha.30. The React runtime is
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
2
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
3
3
  export const NOVO_IDLE_RUN_STATE = Object.freeze({
4
4
  messages: Object.freeze([]),
5
5
  status: 'queued',
@@ -22,7 +22,17 @@ function createControllerSlot() {
22
22
  const listeners = new Set();
23
23
  let controller;
24
24
  let unsubscribe;
25
+ let snapshot = {
26
+ controller: undefined,
27
+ runKey: UNOWNED_RUN_KEY,
28
+ state: NOVO_IDLE_RUN_STATE,
29
+ };
25
30
  const publish = () => {
31
+ snapshot = {
32
+ ...snapshot,
33
+ state: controller?.getSnapshot() ??
34
+ NOVO_IDLE_RUN_STATE,
35
+ };
26
36
  for (const listener of listeners)
27
37
  listener();
28
38
  };
@@ -31,15 +41,20 @@ function createControllerSlot() {
31
41
  listeners.add(listener);
32
42
  return () => listeners.delete(listener);
33
43
  },
34
- getSnapshot: () => controller?.getSnapshot() ??
35
- NOVO_IDLE_RUN_STATE,
44
+ getSnapshot: () => snapshot,
36
45
  getController: () => controller,
37
- setController(next) {
38
- if (controller === next)
46
+ setController(next, runKey = UNOWNED_RUN_KEY) {
47
+ if (controller === next && snapshot.runKey === runKey)
39
48
  return;
40
49
  unsubscribe?.();
41
50
  controller = next;
42
51
  unsubscribe = next?.subscribe(publish);
52
+ snapshot = {
53
+ controller: next,
54
+ runKey,
55
+ state: next?.getSnapshot() ??
56
+ NOVO_IDLE_RUN_STATE,
57
+ };
43
58
  publish();
44
59
  },
45
60
  };
@@ -51,11 +66,10 @@ function createControllerSlot() {
51
66
  */
52
67
  export function useNovoRun(options) {
53
68
  const factoryRef = useRef(options.createController);
54
- factoryRef.current = options.createController;
55
- const slotRef = useRef(undefined);
56
- slotRef.current ??= createControllerSlot();
57
- const slot = slotRef.current;
58
- const ownedRunKeyRef = useRef(UNOWNED_RUN_KEY);
69
+ useEffect(() => {
70
+ factoryRef.current = options.createController;
71
+ }, [options.createController]);
72
+ const [slot] = useState(() => createControllerSlot());
59
73
  // Normalize before any dependency array: `enabled: undefined <-> true` and
60
74
  // `runKey: undefined <-> null` toggles are identity changes and must not
61
75
  // dispose and recreate a live controller.
@@ -67,14 +81,10 @@ export function useNovoRun(options) {
67
81
  return;
68
82
  }
69
83
  const controller = factoryRef.current();
70
- ownedRunKeyRef.current = runKey;
71
- slot.setController(controller);
84
+ slot.setController(controller, runKey);
72
85
  return () => {
73
86
  controller.stop();
74
87
  controller.dispose();
75
- if (ownedRunKeyRef.current === runKey) {
76
- ownedRunKeyRef.current = UNOWNED_RUN_KEY;
77
- }
78
88
  if (slot.getController() === controller)
79
89
  slot.setController(undefined);
80
90
  };
@@ -82,16 +92,16 @@ export function useNovoRun(options) {
82
92
  useEffect(() => {
83
93
  if (enabled &&
84
94
  options.autoStart !== false &&
85
- ownedRunKeyRef.current === runKey) {
95
+ slot.getSnapshot().runKey === runKey) {
86
96
  void slot.getController()?.start();
87
97
  }
88
98
  }, [options.autoStart, enabled, runKey, slot]);
89
- const observedState = useSyncExternalStore(slot.subscribe, slot.getSnapshot, () => NOVO_IDLE_RUN_STATE);
90
- const ownsRenderedKey = enabled && ownedRunKeyRef.current === runKey;
99
+ const observed = useSyncExternalStore(slot.subscribe, slot.getSnapshot, () => IDLE_SLOT_SNAPSHOT);
100
+ const ownsRenderedKey = enabled && observed.runKey === runKey;
91
101
  const state = ownsRenderedKey
92
- ? observedState
102
+ ? observed.state
93
103
  : NOVO_IDLE_RUN_STATE;
94
- const controller = ownsRenderedKey ? slot.getController() : undefined;
104
+ const controller = ownsRenderedKey ? observed.controller : undefined;
95
105
  return useMemo(() => ({
96
106
  ...state,
97
107
  controller,
@@ -105,3 +115,8 @@ export function useNovoRun(options) {
105
115
  }), [controller, state]);
106
116
  }
107
117
  const UNOWNED_RUN_KEY = Symbol('unowned Novo run key');
118
+ const IDLE_SLOT_SNAPSHOT = Object.freeze({
119
+ controller: undefined,
120
+ runKey: UNOWNED_RUN_KEY,
121
+ state: NOVO_IDLE_RUN_STATE,
122
+ });
@@ -1,47 +1,67 @@
1
1
  'use client';
2
- import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore, } from 'react';
2
+ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, } from 'react';
3
3
  import { NOVO_IDLE_RUN_STATE } from './useNovoRun.js';
4
4
  /** Concurrent-safe selector binding with selector/equality identity support. */
5
5
  export function useNovoRunSelector(controller, selector, isEqual = Object.is) {
6
- const committedSelection = useRef({
7
- hasValue: false,
8
- });
9
- const idleState = NOVO_IDLE_RUN_STATE;
10
- const [getSnapshot, getServerSnapshot] = useMemo(() => {
11
- let hasMemo = false;
12
- let memoizedState;
13
- let memoizedSelection;
14
- const select = (state) => {
15
- if (!hasMemo) {
16
- hasMemo = true;
17
- memoizedState = state;
18
- const nextSelection = selector(state);
19
- const committed = committedSelection.current;
20
- memoizedSelection =
21
- committed.hasValue && isEqual(committed.value, nextSelection)
22
- ? committed.value
23
- : nextSelection;
24
- return memoizedSelection;
25
- }
26
- if (Object.is(memoizedState, state))
27
- return memoizedSelection;
28
- const nextSelection = selector(state);
29
- memoizedState = state;
30
- if (isEqual(memoizedSelection, nextSelection))
31
- return memoizedSelection;
32
- memoizedSelection = nextSelection;
33
- return memoizedSelection;
34
- };
35
- const serverSelection = select(idleState);
36
- return [
37
- () => select(controller?.getSnapshot() ?? idleState),
38
- () => serverSelection,
39
- ];
40
- }, [controller, isEqual, selector]);
6
+ const [committedSelection] = useState(() => createCommittedSelection());
7
+ const [getSnapshot, getServerSnapshot] = useMemo(() => createSelectionReader(controller, selector, isEqual, NOVO_IDLE_RUN_STATE, committedSelection), [committedSelection, controller, isEqual, selector]);
41
8
  const subscribe = useCallback((listener) => controller?.subscribe(listener) ?? (() => { }), [controller]);
42
9
  const selection = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
43
10
  useEffect(() => {
44
- committedSelection.current = { hasValue: true, value: selection };
45
- }, [selection]);
11
+ committedSelection.write(selection);
12
+ }, [committedSelection, selection]);
46
13
  return selection;
47
14
  }
15
+ // Deliberately a mutable cell read during render (via getSnapshot) and
16
+ // written from the commit effect, mirroring React's own
17
+ // useSyncExternalStoreWithSelector shim. The hooks lint layer cannot see a
18
+ // closure-held cell; do not "fix" this back into a ref, which it would flag.
19
+ function createCommittedSelection() {
20
+ let committed = {
21
+ hasValue: false,
22
+ };
23
+ return {
24
+ read: () => committed,
25
+ write: (value) => {
26
+ committed = { hasValue: true, value };
27
+ },
28
+ };
29
+ }
30
+ function createSelectionReader(controller, selector, isEqual, idleState, committedSelection) {
31
+ let hasValue = false;
32
+ let value;
33
+ let memoizedState;
34
+ let hasServerValue = false;
35
+ let serverValue;
36
+ const select = (state) => {
37
+ if (Object.is(memoizedState, state) && hasValue)
38
+ return value;
39
+ const next = selector(state);
40
+ memoizedState = state;
41
+ if (!hasValue) {
42
+ hasValue = true;
43
+ const committed = committedSelection.read();
44
+ value =
45
+ committed.hasValue && isEqual(committed.value, next)
46
+ ? committed.value
47
+ : next;
48
+ }
49
+ else if (!isEqual(value, next)) {
50
+ value = next;
51
+ }
52
+ return value;
53
+ };
54
+ const selectServer = () => {
55
+ if (hasServerValue)
56
+ return serverValue;
57
+ const next = selector(idleState);
58
+ const committed = committedSelection.read();
59
+ serverValue =
60
+ committed.hasValue && isEqual(committed.value, next)
61
+ ? committed.value
62
+ : next;
63
+ hasServerValue = true;
64
+ return serverValue;
65
+ };
66
+ return [() => select(controller?.getSnapshot() ?? idleState), selectServer];
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novoagents/react",
3
- "version": "0.3.0-alpha.34",
3
+ "version": "0.3.0-alpha.36",
4
4
  "description": "Browser-safe React hooks for Novo Agents streams and interactions.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -23,14 +23,14 @@
23
23
  "access": "public"
24
24
  },
25
25
  "peerDependencies": {
26
- "novoagents": "0.5.0-alpha.30",
26
+ "novoagents": "0.5.0-alpha.32",
27
27
  "react": "^18.0.0 || ^19.0.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/react": "19.2.18",
31
31
  "@types/react-dom": "19.2.4",
32
32
  "happy-dom": "20.11.6",
33
- "novoagents": "0.5.0-alpha.30",
33
+ "novoagents": "0.5.0-alpha.32",
34
34
  "react": "19.2.8",
35
35
  "react-dom": "19.2.8",
36
36
  "typescript": "6.0.3",