1 | import { Reaction } from "mobx"
|
2 | import React from "react"
|
3 | import { printDebugValue } from "./utils/printDebugValue"
|
4 | import { isUsingStaticRendering } from "./staticRendering"
|
5 | import { observerFinalizationRegistry } from "./utils/observerFinalizationRegistry"
|
6 | import { useSyncExternalStore } from "use-sync-external-store/shim"
|
7 |
|
8 | // Do not store `admRef` (even as part of a closure!) on this object,
|
9 | // otherwise it will prevent GC and therefore reaction disposal via FinalizationRegistry.
|
10 | type ObserverAdministration = {
|
11 | reaction: Reaction | null // also serves as disposed flag
|
12 | onStoreChange: Function | null // also serves as mounted flag
|
13 | // stateVersion that 'ticks' for every time the reaction fires
|
14 | // tearing is still present,
|
15 | // because there is no cross component synchronization,
|
16 | // but we can use `useSyncExternalStore` API.
|
17 | // TODO: optimize to use number?
|
18 | stateVersion: any
|
19 | name: string
|
20 | // These don't depend on state/props, therefore we can keep them here instead of `useCallback`
|
21 | subscribe: Parameters<typeof React.useSyncExternalStore>[0]
|
22 | getSnapshot: Parameters<typeof React.useSyncExternalStore>[1]
|
23 | }
|
24 |
|
25 | function createReaction(adm: ObserverAdministration) {
|
26 | adm.reaction = new Reaction(`observer${adm.name}`, () => {
|
27 | adm.stateVersion = Symbol()
|
28 | // onStoreChange won't be available until the component "mounts".
|
29 | // If state changes in between initial render and mount,
|
30 | // `useSyncExternalStore` should handle that by checking the state version and issuing update.
|
31 | adm.onStoreChange?.()
|
32 | })
|
33 | }
|
34 |
|
35 | export function useObserver<T>(render: () => T, baseComponentName: string = "observed"): T {
|
36 | if (isUsingStaticRendering()) {
|
37 | return render()
|
38 | }
|
39 |
|
40 | const admRef = React.useRef<ObserverAdministration | null>(null)
|
41 |
|
42 | if (!admRef.current) {
|
43 | // First render
|
44 | const adm: ObserverAdministration = {
|
45 | reaction: null,
|
46 | onStoreChange: null,
|
47 | stateVersion: Symbol(),
|
48 | name: baseComponentName,
|
49 | subscribe(onStoreChange: () => void) {
|
50 | // Do NOT access admRef here!
|
51 | observerFinalizationRegistry.unregister(adm)
|
52 | adm.onStoreChange = onStoreChange
|
53 | if (!adm.reaction) {
|
54 | // We've lost our reaction and therefore all subscriptions, occurs when:
|
55 | // 1. Timer based finalization registry disposed reaction before component mounted.
|
56 | // 2. React "re-mounts" same component without calling render in between (typically <StrictMode>).
|
57 | // We have to recreate reaction and schedule re-render to recreate subscriptions,
|
58 | // even if state did not change.
|
59 | createReaction(adm)
|
60 | // `onStoreChange` won't force update if subsequent `getSnapshot` returns same value.
|
61 | // So we make sure that is not the case
|
62 | adm.stateVersion = Symbol()
|
63 | }
|
64 |
|
65 | return () => {
|
66 | // Do NOT access admRef here!
|
67 | adm.onStoreChange = null
|
68 | adm.reaction?.dispose()
|
69 | adm.reaction = null
|
70 | }
|
71 | },
|
72 | getSnapshot() {
|
73 | // Do NOT access admRef here!
|
74 | return adm.stateVersion
|
75 | }
|
76 | }
|
77 |
|
78 | admRef.current = adm
|
79 | }
|
80 |
|
81 | const adm = admRef.current!
|
82 |
|
83 | if (!adm.reaction) {
|
84 | // First render or reaction was disposed by registry before subscribe
|
85 | createReaction(adm)
|
86 | // StrictMode/ConcurrentMode/Suspense may mean that our component is
|
87 | // rendered and abandoned multiple times, so we need to track leaked
|
88 | // Reactions.
|
89 | observerFinalizationRegistry.register(admRef, adm, adm)
|
90 | }
|
91 |
|
92 | React.useDebugValue(adm.reaction!, printDebugValue)
|
93 |
|
94 | useSyncExternalStore(
|
95 | // Both of these must be stable, otherwise it would keep resubscribing every render.
|
96 | adm.subscribe,
|
97 | adm.getSnapshot,
|
98 | adm.getSnapshot
|
99 | )
|
100 |
|
101 | // render the original component, but have the
|
102 | // reaction track the observables, so that rendering
|
103 | // can be invalidated (see above) once a dependency changes
|
104 | let renderResult!: T
|
105 | let exception
|
106 | adm.reaction!.track(() => {
|
107 | try {
|
108 | renderResult = render()
|
109 | } catch (e) {
|
110 | exception = e
|
111 | }
|
112 | })
|
113 |
|
114 | if (exception) {
|
115 | throw exception // re-throw any exceptions caught during rendering
|
116 | }
|
117 |
|
118 | return renderResult
|
119 | }
|