@novoagents/react 0.3.0-alpha.35 → 0.3.0-alpha.37

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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0-alpha.37 - 2026-09-03
4
+
5
+ Companion release for `novoagents` 0.5.0-alpha.33. The React runtime is
6
+ unchanged; the exact peer carries the new agent configuration warnings type.
7
+
8
+ ## 0.3.0-alpha.36 - 2026-09-01
9
+
10
+ Companion release for `novoagents` 0.5.0-alpha.32.
11
+
12
+ - Controller ownership now rides the external-store snapshot (run key and
13
+ controller travel with the subscribed state), so an abandoned concurrent
14
+ render can no longer strand ownership in a render-written ref. Selector
15
+ readers isolate speculative renders while preserving equal object identity
16
+ across inline selector changes: the committed selection lives in a
17
+ `useState`-held cell that is still read during render, by design, mirroring
18
+ React's own `useSyncExternalStoreWithSelector` shim. Behavior is unchanged
19
+ across React 18 and 19, and the package now passes the canonical hooks lint
20
+ rules.
21
+
3
22
  ## 0.3.0-alpha.35 - 2026-08-26
4
23
 
5
24
  - Companion release for `novoagents` 0.5.0-alpha.31. GitHub connection and
@@ -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.35",
3
+ "version": "0.3.0-alpha.37",
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.31",
26
+ "novoagents": "0.5.0-alpha.33",
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.31",
33
+ "novoagents": "0.5.0-alpha.33",
34
34
  "react": "19.2.8",
35
35
  "react-dom": "19.2.8",
36
36
  "typescript": "6.0.3",