ccstate-react 4.12.0 → 4.13.0

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,15 @@
1
1
  # ccstate-react
2
2
 
3
+ ## 4.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8f06427: refactor: avoid unhandledRejection in useLoadable
8
+
9
+ ### Patch Changes
10
+
11
+ - ccstate@4.13.0
12
+
3
13
  ## 4.12.0
4
14
 
5
15
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -13,30 +13,12 @@ function useStore() {
13
13
  return store;
14
14
  }
15
15
 
16
- function useGetInternal(atom, _ref) {
17
- var silenceUnhandleRejection = _ref.silenceUnhandleRejection;
16
+ function useGet(atom) {
18
17
  var store = useStore();
19
18
  return react.useSyncExternalStore(function (fn) {
20
- var ctrl = new AbortController();
21
- store.sub(atom, ccstate.command(fn), {
22
- signal: ctrl.signal
23
- });
24
- return function () {
25
- ctrl.abort();
26
- };
19
+ return store.sub(atom, ccstate.command(fn));
27
20
  }, function () {
28
- var val = store.get(atom);
29
- if (val instanceof Promise && silenceUnhandleRejection) {
30
- val["catch"](function () {
31
- return void 0;
32
- });
33
- }
34
- return val;
35
- });
36
- }
37
- function useGet(atom) {
38
- return useGetInternal(atom, {
39
- silenceUnhandleRejection: false
21
+ return store.get(atom);
40
22
  });
41
23
  }
42
24
 
@@ -98,10 +80,47 @@ function _unsupportedIterableToArray(r, a) {
98
80
  }
99
81
  }
100
82
 
101
- function useLoadableInternal(atom, keepLastResolved) {
102
- var promise = useGetInternal(atom, {
103
- silenceUnhandleRejection: true
83
+ /**
84
+ * Handles a specific behavior of useSyncExternalStore. In React, there are situations where the getSnapshot function of
85
+ * useSyncExternalStore executes, but the Render function doesn't execute.
86
+ *
87
+ * This can cause the promise generated in that round to not be caught, and userspace has no opportunity to handle this
88
+ * promise. Therefore, this issue needs to be handled in useGetPromise.
89
+ *
90
+ * @param atom
91
+ * @returns
92
+ */
93
+ function useGetPromise(atom) {
94
+ var store = useStore();
95
+ var lastPromise = react.useRef(undefined);
96
+ var promiseProcessed = react.useRef(false);
97
+ var promise = react.useSyncExternalStore(function (fn) {
98
+ return store.sub(atom, ccstate.command(fn));
99
+ }, function () {
100
+ var val = store.get(atom);
101
+
102
+ // If the last promise is not processed and the current value is a promise,
103
+ // we need to silence the last promise to avoid unhandled rejections.
104
+ if (lastPromise.current !== undefined && lastPromise.current !== val && !promiseProcessed.current) {
105
+ lastPromise.current["catch"](function () {
106
+ return void 0;
107
+ });
108
+ }
109
+ if (lastPromise.current !== val) {
110
+ promiseProcessed.current = false;
111
+ lastPromise.current = val instanceof Promise ? val : undefined;
112
+ }
113
+ return val;
104
114
  });
115
+ return [promise, function () {
116
+ promiseProcessed.current = true;
117
+ }];
118
+ }
119
+ function useLoadableInternal(atom, keepLastResolved) {
120
+ var _useGetPromise = useGetPromise(atom),
121
+ _useGetPromise2 = _slicedToArray(_useGetPromise, 2),
122
+ promise = _useGetPromise2[0],
123
+ setPromiseProcessed = _useGetPromise2[1];
105
124
  var _useState = react.useState({
106
125
  state: 'loading'
107
126
  }),
@@ -116,28 +135,28 @@ function useLoadableInternal(atom, keepLastResolved) {
116
135
  });
117
136
  return;
118
137
  }
119
- var ctrl = new AbortController();
120
- var signal = ctrl.signal;
138
+ var cancelled = false;
121
139
  if (!keepLastResolved) {
122
140
  setPromiseResult({
123
141
  state: 'loading'
124
142
  });
125
143
  }
144
+ setPromiseProcessed();
126
145
  promise.then(function (ret) {
127
- if (signal.aborted) return;
146
+ if (cancelled) return;
128
147
  setPromiseResult({
129
148
  state: 'hasData',
130
149
  data: ret
131
150
  });
132
151
  }, function (error) {
133
- if (signal.aborted) return;
152
+ if (cancelled) return;
134
153
  setPromiseResult({
135
154
  state: 'hasError',
136
155
  error: error
137
156
  });
138
157
  });
139
158
  return function () {
140
- ctrl.abort();
159
+ cancelled = true;
141
160
  };
142
161
  }, [promise]);
143
162
  return promiseResult;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createContext, useContext, useSyncExternalStore, useCallback, useState, useEffect } from 'react';
1
+ import { createContext, useContext, useSyncExternalStore, useCallback, useState, useEffect, useRef } from 'react';
2
2
  import { getDefaultStore, command } from 'ccstate';
3
3
 
4
4
  var StoreContext = createContext(null);
@@ -11,30 +11,12 @@ function useStore() {
11
11
  return store;
12
12
  }
13
13
 
14
- function useGetInternal(atom, _ref) {
15
- var silenceUnhandleRejection = _ref.silenceUnhandleRejection;
14
+ function useGet(atom) {
16
15
  var store = useStore();
17
16
  return useSyncExternalStore(function (fn) {
18
- var ctrl = new AbortController();
19
- store.sub(atom, command(fn), {
20
- signal: ctrl.signal
21
- });
22
- return function () {
23
- ctrl.abort();
24
- };
17
+ return store.sub(atom, command(fn));
25
18
  }, function () {
26
- var val = store.get(atom);
27
- if (val instanceof Promise && silenceUnhandleRejection) {
28
- val["catch"](function () {
29
- return void 0;
30
- });
31
- }
32
- return val;
33
- });
34
- }
35
- function useGet(atom) {
36
- return useGetInternal(atom, {
37
- silenceUnhandleRejection: false
19
+ return store.get(atom);
38
20
  });
39
21
  }
40
22
 
@@ -96,10 +78,47 @@ function _unsupportedIterableToArray(r, a) {
96
78
  }
97
79
  }
98
80
 
99
- function useLoadableInternal(atom, keepLastResolved) {
100
- var promise = useGetInternal(atom, {
101
- silenceUnhandleRejection: true
81
+ /**
82
+ * Handles a specific behavior of useSyncExternalStore. In React, there are situations where the getSnapshot function of
83
+ * useSyncExternalStore executes, but the Render function doesn't execute.
84
+ *
85
+ * This can cause the promise generated in that round to not be caught, and userspace has no opportunity to handle this
86
+ * promise. Therefore, this issue needs to be handled in useGetPromise.
87
+ *
88
+ * @param atom
89
+ * @returns
90
+ */
91
+ function useGetPromise(atom) {
92
+ var store = useStore();
93
+ var lastPromise = useRef(undefined);
94
+ var promiseProcessed = useRef(false);
95
+ var promise = useSyncExternalStore(function (fn) {
96
+ return store.sub(atom, command(fn));
97
+ }, function () {
98
+ var val = store.get(atom);
99
+
100
+ // If the last promise is not processed and the current value is a promise,
101
+ // we need to silence the last promise to avoid unhandled rejections.
102
+ if (lastPromise.current !== undefined && lastPromise.current !== val && !promiseProcessed.current) {
103
+ lastPromise.current["catch"](function () {
104
+ return void 0;
105
+ });
106
+ }
107
+ if (lastPromise.current !== val) {
108
+ promiseProcessed.current = false;
109
+ lastPromise.current = val instanceof Promise ? val : undefined;
110
+ }
111
+ return val;
102
112
  });
113
+ return [promise, function () {
114
+ promiseProcessed.current = true;
115
+ }];
116
+ }
117
+ function useLoadableInternal(atom, keepLastResolved) {
118
+ var _useGetPromise = useGetPromise(atom),
119
+ _useGetPromise2 = _slicedToArray(_useGetPromise, 2),
120
+ promise = _useGetPromise2[0],
121
+ setPromiseProcessed = _useGetPromise2[1];
103
122
  var _useState = useState({
104
123
  state: 'loading'
105
124
  }),
@@ -114,28 +133,28 @@ function useLoadableInternal(atom, keepLastResolved) {
114
133
  });
115
134
  return;
116
135
  }
117
- var ctrl = new AbortController();
118
- var signal = ctrl.signal;
136
+ var cancelled = false;
119
137
  if (!keepLastResolved) {
120
138
  setPromiseResult({
121
139
  state: 'loading'
122
140
  });
123
141
  }
142
+ setPromiseProcessed();
124
143
  promise.then(function (ret) {
125
- if (signal.aborted) return;
144
+ if (cancelled) return;
126
145
  setPromiseResult({
127
146
  state: 'hasData',
128
147
  data: ret
129
148
  });
130
149
  }, function (error) {
131
- if (signal.aborted) return;
150
+ if (cancelled) return;
132
151
  setPromiseResult({
133
152
  state: 'hasError',
134
153
  error: error
135
154
  });
136
155
  });
137
156
  return function () {
138
- ctrl.abort();
157
+ cancelled = true;
139
158
  };
140
159
  }, [promise]);
141
160
  return promiseResult;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccstate-react",
3
- "version": "4.12.0",
3
+ "version": "4.13.0",
4
4
  "description": "CCState React Hooks",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,7 +25,7 @@
25
25
  "react": ">=17.0.0"
26
26
  },
27
27
  "dependencies": {
28
- "ccstate": "^4.12.0"
28
+ "ccstate": "^4.13.0"
29
29
  },
30
30
  "peerDependenciesMeta": {
31
31
  "@types/react": {
@@ -55,7 +55,7 @@
55
55
  "shx": "^0.3.4",
56
56
  "signal-timers": "^1.0.4",
57
57
  "vitest": "^2.1.8",
58
- "ccstate": "^4.12.0"
58
+ "ccstate": "^4.13.0"
59
59
  },
60
60
  "scripts": {
61
61
  "build": "rollup -c",
@@ -3,7 +3,7 @@
3
3
  import '@testing-library/jest-dom/vitest';
4
4
  import { render, cleanup, screen } from '@testing-library/react';
5
5
  import userEvent from '@testing-library/user-event';
6
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+ import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest';
7
7
  import { computed, createStore, state } from 'ccstate';
8
8
  import type { Computed, State } from 'ccstate';
9
9
  import { StrictMode, useEffect } from 'react';
@@ -583,9 +583,11 @@ describe('works with AbortError', () => {
583
583
  }
584
584
 
585
585
  render(
586
- <StoreProvider value={store}>
587
- <App />
588
- </StoreProvider>,
586
+ <StrictMode>
587
+ <StoreProvider value={store}>
588
+ <App />
589
+ </StoreProvider>
590
+ </StrictMode>,
589
591
  );
590
592
 
591
593
  expect(screen.getByText('Test')).toBeInTheDocument();
@@ -594,3 +596,41 @@ describe('works with AbortError', () => {
594
596
  store.set(reload$, (x) => x + 1);
595
597
  });
596
598
  });
599
+
600
+ test('useLoadable should catch errors', () => {
601
+ const reload$ = state(0);
602
+
603
+ const traceCatch = vi.fn();
604
+ const promise$ = computed((get) => {
605
+ get(reload$);
606
+
607
+ const p = Promise.resolve();
608
+ const originalCatch = p.catch.bind(p);
609
+ vi.spyOn(p, 'catch').mockImplementation((...args) => {
610
+ traceCatch();
611
+ return originalCatch(...args);
612
+ });
613
+ return p;
614
+ });
615
+
616
+ const store = createStore();
617
+
618
+ function App() {
619
+ useLoadable(promise$);
620
+
621
+ return null;
622
+ }
623
+
624
+ render(
625
+ <StrictMode>
626
+ <StoreProvider value={store}>
627
+ <App />
628
+ </StoreProvider>
629
+ </StrictMode>,
630
+ );
631
+
632
+ store.set(reload$, (x) => x + 1);
633
+ store.set(reload$, (x) => x + 1);
634
+
635
+ expect(traceCatch).toHaveBeenCalledTimes(1);
636
+ });
package/src/useGet.ts CHANGED
@@ -3,29 +3,10 @@ import { useStore } from './provider';
3
3
  import { command } from 'ccstate';
4
4
  import type { Computed, State } from 'ccstate';
5
5
 
6
- export function useGetInternal<T>(
7
- atom: State<T> | Computed<T>,
8
- { silenceUnhandleRejection }: { silenceUnhandleRejection: boolean },
9
- ) {
6
+ export function useGet<T>(atom: State<T> | Computed<T>) {
10
7
  const store = useStore();
11
8
  return useSyncExternalStore(
12
- (fn) => {
13
- const ctrl = new AbortController();
14
- store.sub(atom, command(fn), { signal: ctrl.signal });
15
- return () => {
16
- ctrl.abort();
17
- };
18
- },
19
- () => {
20
- const val = store.get(atom);
21
- if (val instanceof Promise && silenceUnhandleRejection) {
22
- val.catch(() => void 0);
23
- }
24
- return val;
25
- },
9
+ (fn) => store.sub(atom, command(fn)),
10
+ () => store.get(atom),
26
11
  );
27
12
  }
28
-
29
- export function useGet<T>(atom: State<T> | Computed<T>) {
30
- return useGetInternal(atom, { silenceUnhandleRejection: false });
31
- }
@@ -1,6 +1,6 @@
1
- import { useEffect, useState } from 'react';
2
- import { useGetInternal } from './useGet';
3
- import type { Computed, State } from 'ccstate';
1
+ import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
2
+ import { command, type Computed, type State } from 'ccstate';
3
+ import { useStore } from './provider';
4
4
 
5
5
  type Loadable<T> =
6
6
  | {
@@ -15,14 +15,54 @@ type Loadable<T> =
15
15
  error: unknown;
16
16
  };
17
17
 
18
+ /**
19
+ * Handles a specific behavior of useSyncExternalStore. In React, there are situations where the getSnapshot function of
20
+ * useSyncExternalStore executes, but the Render function doesn't execute.
21
+ *
22
+ * This can cause the promise generated in that round to not be caught, and userspace has no opportunity to handle this
23
+ * promise. Therefore, this issue needs to be handled in useGetPromise.
24
+ *
25
+ * @param atom
26
+ * @returns
27
+ */
28
+ function useGetPromise<T, U extends Promise<T> | T>(atom: State<U> | Computed<U>): [U, () => void] {
29
+ const store = useStore();
30
+ const lastPromise = useRef<Promise<unknown> | undefined>(undefined);
31
+ const promiseProcessed = useRef(false);
32
+
33
+ const promise = useSyncExternalStore(
34
+ (fn) => store.sub(atom, command(fn)),
35
+ () => {
36
+ const val = store.get(atom);
37
+
38
+ // If the last promise is not processed and the current value is a promise,
39
+ // we need to silence the last promise to avoid unhandled rejections.
40
+ if (lastPromise.current !== undefined && lastPromise.current !== val && !promiseProcessed.current) {
41
+ lastPromise.current.catch(() => void 0);
42
+ }
43
+
44
+ if (lastPromise.current !== val) {
45
+ promiseProcessed.current = false;
46
+ lastPromise.current = val instanceof Promise ? val : undefined;
47
+ }
48
+
49
+ return val;
50
+ },
51
+ );
52
+
53
+ return [
54
+ promise,
55
+ () => {
56
+ promiseProcessed.current = true;
57
+ },
58
+ ];
59
+ }
60
+
18
61
  function useLoadableInternal<T>(
19
62
  atom: State<Promise<T> | T> | Computed<Promise<T> | T>,
20
63
  keepLastResolved: boolean,
21
64
  ): Loadable<T> {
22
- const promise = useGetInternal(atom, {
23
- silenceUnhandleRejection: true,
24
- });
25
-
65
+ const [promise, setPromiseProcessed] = useGetPromise(atom);
26
66
  const [promiseResult, setPromiseResult] = useState<Loadable<T>>({
27
67
  state: 'loading',
28
68
  });
@@ -37,8 +77,7 @@ function useLoadableInternal<T>(
37
77
  return;
38
78
  }
39
79
 
40
- const ctrl = new AbortController();
41
- const signal = ctrl.signal;
80
+ let cancelled = false;
42
81
 
43
82
  if (!keepLastResolved) {
44
83
  setPromiseResult({
@@ -46,9 +85,11 @@ function useLoadableInternal<T>(
46
85
  });
47
86
  }
48
87
 
88
+ setPromiseProcessed();
89
+
49
90
  promise.then(
50
91
  (ret) => {
51
- if (signal.aborted) return;
92
+ if (cancelled) return;
52
93
 
53
94
  setPromiseResult({
54
95
  state: 'hasData',
@@ -56,7 +97,7 @@ function useLoadableInternal<T>(
56
97
  });
57
98
  },
58
99
  (error: unknown) => {
59
- if (signal.aborted) return;
100
+ if (cancelled) return;
60
101
 
61
102
  setPromiseResult({
62
103
  state: 'hasError',
@@ -66,7 +107,7 @@ function useLoadableInternal<T>(
66
107
  );
67
108
 
68
109
  return () => {
69
- ctrl.abort();
110
+ cancelled = true;
70
111
  };
71
112
  }, [promise]);
72
113