Skip to content

Commit 62b5d5c

Browse files
tim-smartdatner
authored andcommitted
useSyncExternalStore for useResultCallback
1 parent 35ee3ab commit 62b5d5c

File tree

5 files changed

+179
-103
lines changed

5 files changed

+179
-103
lines changed

src/hooks/useResult.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,5 @@ export const makeUseResult: <R>(
6363
}
6464
}, [runtime, stream])
6565

66-
trackRef.current.currentStatus = result._tag
6766
return resultBag
6867
}

src/hooks/useResultBag.ts

Lines changed: 72 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export interface TrackedProperties {
4040
failureCause: Cause.Cause<unknown>
4141
}
4242

43-
const initial: TrackedProperties = {
43+
export const initialTrackedProps = (): TrackedProperties => ({
4444
dataUpdatedAt: Option.none(),
4545
errorUpdatedAt: Option.none(),
4646
currentErrorCount: 0,
@@ -51,7 +51,7 @@ const initial: TrackedProperties = {
5151
interruptCount: 0,
5252
currentStatus: "Initial",
5353
failureCause: Cause.empty
54-
}
54+
})
5555

5656
const optionDateGreaterThan = pipe(
5757
N.Order,
@@ -62,8 +62,18 @@ const optionDateGreaterThan = pipe(
6262

6363
export const updateNext = <E, A>(
6464
next: Result.Result<E, A>,
65-
ref: MutableRefObject<TrackedProperties>
65+
tracked: MutableRefObject<TrackedProperties>
6666
): Result.Result<E, A> => {
67+
updateTrackedProps(next, tracked.current)
68+
return next
69+
}
70+
71+
export const updateTrackedProps = <E, A>(
72+
next: Result.Result<E, A>,
73+
tracked: TrackedProperties
74+
) => {
75+
tracked.currentStatus = next._tag
76+
6777
switch (next._tag) {
6878
case "Initial": {
6979
break
@@ -73,73 +83,80 @@ export const updateNext = <E, A>(
7383
}
7484
case "Failure": {
7585
if (Cause.isFailure(next.cause)) {
76-
ref.current.currentFailureCount++
77-
ref.current.currentErrorCount++
78-
ref.current.runningErrorCount++
79-
ref.current.errorUpdatedAt = Option.some(new Date())
86+
tracked.currentFailureCount++
87+
tracked.currentErrorCount++
88+
tracked.runningErrorCount++
89+
tracked.errorUpdatedAt = Option.some(new Date())
8090
break
8191
}
8292
if (!Cause.isInterruptedOnly(next.cause)) {
83-
ref.current.currentDefectCount++
84-
ref.current.currentErrorCount++
85-
ref.current.runningErrorCount++
93+
tracked.currentDefectCount++
94+
tracked.currentErrorCount++
95+
tracked.runningErrorCount++
8696
}
8797

8898
break
8999
}
90100
case "Success": {
91-
ref.current.currentFailureCount = 0
92-
ref.current.currentDefectCount = 0
93-
ref.current.dataUpdatedAt = Option.some(new Date())
101+
tracked.currentFailureCount = 0
102+
tracked.currentDefectCount = 0
103+
tracked.dataUpdatedAt = Option.some(new Date())
94104
break
95105
}
96106
}
97-
return next
98107
}
99108

109+
export const makeResultBag = <E, A>(result: Result.Result<E, A>, tracked: TrackedProperties): ResultBag<E, A> => ({
110+
result,
111+
get isLoading() {
112+
return Result.isLoading(result)
113+
},
114+
get isError() {
115+
return Result.isError(result)
116+
},
117+
get isSuccess() {
118+
return Result.isSuccess(result)
119+
},
120+
get isLoadingFailure() {
121+
return Result.isRetrying(result) && Option.isNone(tracked.dataUpdatedAt)
122+
},
123+
get isRefreshing() {
124+
return Result.isRefreshing(result)
125+
},
126+
get isRetrying() {
127+
return Result.isRetrying(result)
128+
},
129+
get isRefreshingFailure() {
130+
return Result.isRetrying(result)
131+
&& optionDateGreaterThan(tracked.dataUpdatedAt, tracked.errorUpdatedAt)
132+
},
133+
get dataUpdatedAt() {
134+
return tracked.dataUpdatedAt
135+
},
136+
get errorUpdatedAt() {
137+
return tracked.errorUpdatedAt
138+
},
139+
get failureCount() {
140+
return tracked.currentFailureCount
141+
},
142+
get failureCause() {
143+
return tracked.failureCause as Cause.Cause<E>
144+
},
145+
get errorRunningCount() {
146+
return tracked.runningErrorCount
147+
}
148+
})
149+
100150
export const useResultBag = <E, A>(result: Result.Result<E, A>) => {
101-
const trackedPropsRef = useRef<TrackedProperties>(initial)
151+
const trackedPropsRef = useRef<TrackedProperties>(null as any)
152+
if (trackedPropsRef.current === null) {
153+
trackedPropsRef.current = initialTrackedProps()
154+
}
102155

103-
const resultBag = useMemo((): ResultBag<E, A> => ({
104-
result,
105-
get isLoading() {
106-
return Result.isLoading(result)
107-
},
108-
get isError() {
109-
return Result.isError(result)
110-
},
111-
get isSuccess() {
112-
return Result.isSuccess(result)
113-
},
114-
get isLoadingFailure() {
115-
return Result.isRetrying(result) && Option.isNone(trackedPropsRef.current.dataUpdatedAt)
116-
},
117-
get isRefreshing() {
118-
return Result.isRefreshing(result)
119-
},
120-
get isRetrying() {
121-
return Result.isRetrying(result)
122-
},
123-
get isRefreshingFailure() {
124-
return Result.isRetrying(result)
125-
&& optionDateGreaterThan(trackedPropsRef.current.dataUpdatedAt, trackedPropsRef.current.errorUpdatedAt)
126-
},
127-
get dataUpdatedAt() {
128-
return trackedPropsRef.current.dataUpdatedAt
129-
},
130-
get errorUpdatedAt() {
131-
return trackedPropsRef.current.errorUpdatedAt
132-
},
133-
get failureCount() {
134-
return trackedPropsRef.current.currentFailureCount
135-
},
136-
get failureCause() {
137-
return trackedPropsRef.current.failureCause as Cause.Cause<E>
138-
},
139-
get errorRunningCount() {
140-
return trackedPropsRef.current.runningErrorCount
141-
}
142-
}), [result])
156+
const resultBag = useMemo(
157+
(): ResultBag<E, A> => makeResultBag(result, trackedPropsRef.current),
158+
[result]
159+
)
143160

144161
return [trackedPropsRef, resultBag] as const
145162
}

src/hooks/useResultCallback.ts

Lines changed: 100 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,99 @@
1-
import * as Option from "@effect/data/Option"
21
import * as Effect from "@effect/io/Effect"
3-
import * as Fiber from "@effect/io/Fiber"
2+
import type * as Fiber from "@effect/io/Fiber"
3+
import * as Ref from "@effect/io/Ref"
44
import * as Runtime from "@effect/io/Runtime"
55
import * as Stream from "@effect/stream/Stream"
66
import type { ResultBag } from "effect-react/hooks/useResultBag"
7-
import { updateNext, useResultBag } from "effect-react/hooks/useResultBag"
7+
import { initialTrackedProps, makeResultBag, updateTrackedProps } from "effect-react/hooks/useResultBag"
88
import type { RuntimeContext } from "effect-react/internal/runtimeContext"
99
import * as Result from "effect-react/Result"
10-
import { useCallback, useContext, useEffect, useState } from "react"
10+
import { useCallback, useContext, useRef, useSyncExternalStore } from "react"
11+
12+
class FiberStore<R, E, A> {
13+
constructor(
14+
readonly runtime: Runtime.Runtime<R>
15+
) {}
16+
17+
listeners: Array<() => void> = []
18+
19+
subscribe = (listener: () => void) => {
20+
this.listeners.push(listener)
21+
22+
return () => {
23+
this.listeners.splice(this.listeners.indexOf(listener), 1)
24+
25+
queueMicrotask(() => {
26+
if (this.listeners.length === 0) {
27+
this.interruptIfRunning()
28+
}
29+
})
30+
}
31+
}
32+
33+
result: Result.Result<E, A> = Result.initial()
34+
trackedProps = initialTrackedProps()
35+
resultBag = makeResultBag(this.result, this.trackedProps)
36+
37+
setResult(result: Result.Result<E, A>) {
38+
this.result = result
39+
updateTrackedProps(result, this.trackedProps)
40+
this.resultBag = makeResultBag(this.result, this.trackedProps)
41+
42+
for (let i = 0; i < this.listeners.length; i++) {
43+
this.listeners[i]()
44+
}
45+
}
46+
snapshot = () => {
47+
return this.resultBag
48+
}
49+
50+
fiberState:
51+
| {
52+
readonly fiber: Fiber.RuntimeFiber<E, void>
53+
readonly interruptedRef: Ref.Ref<boolean>
54+
}
55+
| undefined = undefined
56+
57+
interruptIfRunning() {
58+
if (this.fiberState) {
59+
Effect.runSync(Ref.set(this.fiberState.interruptedRef, true))
60+
Effect.runFork(
61+
this.fiberState.fiber.interruptAsFork(this.fiberState.fiber.id())
62+
)
63+
}
64+
}
65+
66+
run(stream: Stream.Stream<R, E, A>) {
67+
this.interruptIfRunning()
68+
69+
const interruptedRef = Ref.unsafeMake(false)
70+
const maybeSetResult = (result: Result.Result<E, A>) =>
71+
Effect.flatMap(
72+
Ref.get(interruptedRef),
73+
(interrupted) =>
74+
interrupted
75+
? Effect.unit
76+
: Effect.sync(() => {
77+
this.setResult(result)
78+
})
79+
)
80+
81+
const fiber = Stream.suspend(() => {
82+
this.setResult(Result.waiting(this.result))
83+
return stream
84+
}).pipe(
85+
Stream.tap((value) => maybeSetResult(Result.success(value))),
86+
Stream.tapErrorCause((cause) => maybeSetResult(Result.failCause(cause))),
87+
Stream.runDrain,
88+
Runtime.runFork(this.runtime)
89+
)
90+
91+
this.fiberState = {
92+
fiber,
93+
interruptedRef
94+
}
95+
}
96+
}
1197

1298
export type UseResultCallback<R> = <Args extends Array<any>, R0 extends R, E, A>(
1399
callback: (...args: Args) => Effect.Effect<R0, E, A>
@@ -21,44 +107,17 @@ export const makeUseResultCallback: <R>(
21107
<Args extends Array<any>, R0 extends R, E, A>(
22108
f: (...args: Args) => Stream.Stream<R0, E, A>
23109
) => {
24-
const [result, setResult] = useState<Result.Result<E, A>>(Result.initial())
25-
const [trackRef, resultBag] = useResultBag(result)
26-
trackRef.current.currentStatus = result._tag
27-
28110
const runtime = useContext(runtimeContext)
29-
const [currentArgs, setCurrentArgs] = useState<Option.Option<Args>>(Option.none())
30-
useEffect(() => {
31-
if (Option.isNone(currentArgs)) {
32-
return
33-
}
34-
35-
let interrupting = false
36-
const maybeSetResult = (result: Result.Result<E, A> | ((_: Result.Result<E, A>) => Result.Result<E, A>)) =>
37-
Effect.sync(() => {
38-
if (!interrupting) {
39-
setResult(result)
40-
}
41-
})
42-
43-
const fiber = Stream.suspend(() => {
44-
setResult((prev) => updateNext(Result.waiting(prev), trackRef))
45-
return f(...currentArgs.value)
46-
}).pipe(
47-
Stream.tap((value) => maybeSetResult(updateNext(Result.success(value), trackRef))),
48-
Stream.tapErrorCause((cause) => maybeSetResult(updateNext(Result.failCause(cause), trackRef))),
49-
Stream.runDrain,
50-
Runtime.runFork(runtime)
51-
)
52-
53-
return () => {
54-
interrupting = true
55-
Effect.runFork(Fiber.interruptFork(fiber))
56-
}
57-
}, [f, currentArgs])
58-
111+
const storeRef = useRef<FiberStore<R0, E, A> | undefined>(undefined)
112+
if (storeRef.current === undefined) {
113+
storeRef.current = new FiberStore(runtime)
114+
}
115+
const resultBag = useSyncExternalStore(
116+
storeRef.current!.subscribe,
117+
storeRef.current!.snapshot
118+
)
59119
const run = useCallback((...args: Args) => {
60-
setCurrentArgs(Option.some(args))
61-
}, [])
62-
120+
storeRef.current!.run(f(...args))
121+
}, [f])
63122
return [resultBag, run] as const
64123
}

test/hooks/useResult.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,11 @@ describe("useResult", () => {
2626
it("should provide context", async () => {
2727
const testEffect = Effect.map(foo, (_) => _.value)
2828

29-
const { result } = await waitFor(async () => renderHook(() => useResult(testEffect)))
29+
const renderResult = await waitFor(async () => renderHook(() => useResult(testEffect)))
30+
const result = renderResult.result.current.result
3031

31-
await waitFor(() => expect(Result.isSuccess(result.current.result)).toBe(true))
32-
expect(result.current.result.value).toBe(1)
32+
await waitFor(() => expect(Result.isSuccess(result)).toBe(true))
33+
expect(result._tag === "Success" && result.value).toBe(1)
3334
})
3435

3536
it("should run streams", async () => {

test/hooks/useResultCallback.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const foo = Context.Tag<Foo>()
1313

1414
const { useResultCallback } = RuntimeProvider.makeFromLayer(Layer.succeed(foo, { value: 1 }))
1515

16-
describe("useResult", () => {
16+
describe("useResultCallback", () => {
1717
it("should do good", async () => {
1818
const testEffect = (value: number) => Effect.succeed(value)
1919

@@ -24,11 +24,11 @@ describe("useResult", () => {
2424
result.current[1](1)
2525
})
2626
await waitFor(() => expect(Result.isSuccess(result.current[0].result)).toBe(true))
27-
expect(result.current[0].result.value).toBe(1)
27+
expect(result.current[0].result._tag === "Success" && result.current[0].result.value).toBe(1)
2828
act(() => {
2929
result.current[1](2)
3030
})
3131
await waitFor(() => expect(Result.isSuccess(result.current[0].result)).toBe(true))
32-
expect(result.current[0].result.value).toBe(2)
32+
expect(result.current[0].result._tag === "Success" && result.current[0].result.value).toBe(2)
3333
})
3434
})

0 commit comments

Comments
 (0)