ccstate-react 4.11.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,25 @@
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
+
13
+ ## 4.12.0
14
+
15
+ ### Minor Changes
16
+
17
+ - c8f4fca: fix(react): add global error handler for floating promises in useLoadable
18
+
19
+ ### Patch Changes
20
+
21
+ - ccstate@4.12.0
22
+
3
23
  ## 4.11.0
4
24
 
5
25
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -16,13 +16,7 @@ function useStore() {
16
16
  function useGet(atom) {
17
17
  var store = useStore();
18
18
  return react.useSyncExternalStore(function (fn) {
19
- var ctrl = new AbortController();
20
- store.sub(atom, ccstate.command(fn), {
21
- signal: ctrl.signal
22
- });
23
- return function () {
24
- ctrl.abort();
25
- };
19
+ return store.sub(atom, ccstate.command(fn));
26
20
  }, function () {
27
21
  return store.get(atom);
28
22
  });
@@ -86,8 +80,47 @@ function _unsupportedIterableToArray(r, a) {
86
80
  }
87
81
  }
88
82
 
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;
114
+ });
115
+ return [promise, function () {
116
+ promiseProcessed.current = true;
117
+ }];
118
+ }
89
119
  function useLoadableInternal(atom, keepLastResolved) {
90
- var promise = useGet(atom);
120
+ var _useGetPromise = useGetPromise(atom),
121
+ _useGetPromise2 = _slicedToArray(_useGetPromise, 2),
122
+ promise = _useGetPromise2[0],
123
+ setPromiseProcessed = _useGetPromise2[1];
91
124
  var _useState = react.useState({
92
125
  state: 'loading'
93
126
  }),
@@ -102,28 +135,28 @@ function useLoadableInternal(atom, keepLastResolved) {
102
135
  });
103
136
  return;
104
137
  }
105
- var ctrl = new AbortController();
106
- var signal = ctrl.signal;
138
+ var cancelled = false;
107
139
  if (!keepLastResolved) {
108
140
  setPromiseResult({
109
141
  state: 'loading'
110
142
  });
111
143
  }
144
+ setPromiseProcessed();
112
145
  promise.then(function (ret) {
113
- if (signal.aborted) return;
146
+ if (cancelled) return;
114
147
  setPromiseResult({
115
148
  state: 'hasData',
116
149
  data: ret
117
150
  });
118
151
  }, function (error) {
119
- if (signal.aborted) return;
152
+ if (cancelled) return;
120
153
  setPromiseResult({
121
154
  state: 'hasError',
122
155
  error: error
123
156
  });
124
157
  });
125
158
  return function () {
126
- ctrl.abort();
159
+ cancelled = true;
127
160
  };
128
161
  }, [promise]);
129
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);
@@ -14,13 +14,7 @@ function useStore() {
14
14
  function useGet(atom) {
15
15
  var store = useStore();
16
16
  return useSyncExternalStore(function (fn) {
17
- var ctrl = new AbortController();
18
- store.sub(atom, command(fn), {
19
- signal: ctrl.signal
20
- });
21
- return function () {
22
- ctrl.abort();
23
- };
17
+ return store.sub(atom, command(fn));
24
18
  }, function () {
25
19
  return store.get(atom);
26
20
  });
@@ -84,8 +78,47 @@ function _unsupportedIterableToArray(r, a) {
84
78
  }
85
79
  }
86
80
 
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;
112
+ });
113
+ return [promise, function () {
114
+ promiseProcessed.current = true;
115
+ }];
116
+ }
87
117
  function useLoadableInternal(atom, keepLastResolved) {
88
- var promise = useGet(atom);
118
+ var _useGetPromise = useGetPromise(atom),
119
+ _useGetPromise2 = _slicedToArray(_useGetPromise, 2),
120
+ promise = _useGetPromise2[0],
121
+ setPromiseProcessed = _useGetPromise2[1];
89
122
  var _useState = useState({
90
123
  state: 'loading'
91
124
  }),
@@ -100,28 +133,28 @@ function useLoadableInternal(atom, keepLastResolved) {
100
133
  });
101
134
  return;
102
135
  }
103
- var ctrl = new AbortController();
104
- var signal = ctrl.signal;
136
+ var cancelled = false;
105
137
  if (!keepLastResolved) {
106
138
  setPromiseResult({
107
139
  state: 'loading'
108
140
  });
109
141
  }
142
+ setPromiseProcessed();
110
143
  promise.then(function (ret) {
111
- if (signal.aborted) return;
144
+ if (cancelled) return;
112
145
  setPromiseResult({
113
146
  state: 'hasData',
114
147
  data: ret
115
148
  });
116
149
  }, function (error) {
117
- if (signal.aborted) return;
150
+ if (cancelled) return;
118
151
  setPromiseResult({
119
152
  state: 'hasError',
120
153
  error: error
121
154
  });
122
155
  });
123
156
  return function () {
124
- ctrl.abort();
157
+ cancelled = true;
125
158
  };
126
159
  }, [promise]);
127
160
  return promiseResult;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccstate-react",
3
- "version": "4.11.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.11.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.11.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, 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';
@@ -543,3 +543,94 @@ it('useLoadable accept sync computed', async () => {
543
543
 
544
544
  expect(await screen.findByText('hasData')).toBeInTheDocument();
545
545
  });
546
+
547
+ describe('works with AbortError', () => {
548
+ let abortController: AbortController;
549
+ let promise: Promise<void>;
550
+
551
+ beforeEach(() => {
552
+ abortController = new AbortController();
553
+ });
554
+
555
+ afterEach(async () => {
556
+ abortController.abort();
557
+
558
+ try {
559
+ await promise;
560
+ } catch {}
561
+ });
562
+
563
+ it('should not throw abortError', () => {
564
+ const store = createStore();
565
+ const signal = abortController.signal;
566
+
567
+ promise = new Promise((_resolve, reject) => {
568
+ signal.addEventListener('abort', () => {
569
+ reject(signal.reason as Error);
570
+ });
571
+ });
572
+
573
+ const reload$ = state(0);
574
+ const promise$ = computed(async (get) => {
575
+ get(reload$);
576
+ await promise;
577
+ });
578
+
579
+ function App() {
580
+ useLoadable(promise$);
581
+
582
+ return <div>Test</div>;
583
+ }
584
+
585
+ render(
586
+ <StrictMode>
587
+ <StoreProvider value={store}>
588
+ <App />
589
+ </StoreProvider>
590
+ </StrictMode>,
591
+ );
592
+
593
+ expect(screen.getByText('Test')).toBeInTheDocument();
594
+
595
+ store.set(reload$, (x) => x + 1);
596
+ store.set(reload$, (x) => x + 1);
597
+ });
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
@@ -6,15 +6,7 @@ import type { Computed, State } from 'ccstate';
6
6
  export function useGet<T>(atom: State<T> | Computed<T>) {
7
7
  const store = useStore();
8
8
  return useSyncExternalStore(
9
- (fn) => {
10
- const ctrl = new AbortController();
11
- store.sub(atom, command(fn), { signal: ctrl.signal });
12
- return () => {
13
- ctrl.abort();
14
- };
15
- },
16
- () => {
17
- return store.get(atom);
18
- },
9
+ (fn) => store.sub(atom, command(fn)),
10
+ () => store.get(atom),
19
11
  );
20
12
  }
@@ -1,6 +1,6 @@
1
- import { useEffect, useState } from 'react';
2
- import { useGet } 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,12 +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 = useGet(atom);
23
-
65
+ const [promise, setPromiseProcessed] = useGetPromise(atom);
24
66
  const [promiseResult, setPromiseResult] = useState<Loadable<T>>({
25
67
  state: 'loading',
26
68
  });
@@ -35,8 +77,7 @@ function useLoadableInternal<T>(
35
77
  return;
36
78
  }
37
79
 
38
- const ctrl = new AbortController();
39
- const signal = ctrl.signal;
80
+ let cancelled = false;
40
81
 
41
82
  if (!keepLastResolved) {
42
83
  setPromiseResult({
@@ -44,9 +85,11 @@ function useLoadableInternal<T>(
44
85
  });
45
86
  }
46
87
 
88
+ setPromiseProcessed();
89
+
47
90
  promise.then(
48
91
  (ret) => {
49
- if (signal.aborted) return;
92
+ if (cancelled) return;
50
93
 
51
94
  setPromiseResult({
52
95
  state: 'hasData',
@@ -54,7 +97,7 @@ function useLoadableInternal<T>(
54
97
  });
55
98
  },
56
99
  (error: unknown) => {
57
- if (signal.aborted) return;
100
+ if (cancelled) return;
58
101
 
59
102
  setPromiseResult({
60
103
  state: 'hasError',
@@ -64,7 +107,7 @@ function useLoadableInternal<T>(
64
107
  );
65
108
 
66
109
  return () => {
67
- ctrl.abort();
110
+ cancelled = true;
68
111
  };
69
112
  }, [promise]);
70
113