Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better React 18 support #3590

Merged
merged 42 commits into from
Mar 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
223ce6b
wip
urugator Nov 26, 2022
fbe7604
wip
urugator Nov 27, 2022
6976dec
wip
urugator Nov 27, 2022
08ab24d
wip
urugator Dec 17, 2022
951b7e8
fix test
urugator Dec 18, 2022
abfaa7e
wip
urugator Dec 18, 2022
b5c26ca
refactor
urugator Dec 18, 2022
dfb75fd
refactor
urugator Dec 18, 2022
5006263
refactors
urugator Dec 18, 2022
229fc05
refactors
urugator Dec 18, 2022
5ca1cfe
refactor
urugator Dec 18, 2022
fa05836
use useSyncExternalStore shim
urugator Dec 18, 2022
8b8d948
typo
urugator Dec 18, 2022
27560d4
refactor
urugator Dec 18, 2022
09e471a
Merge remote-tracking branch 'upstream/main' into use-external-sync-s…
urugator Dec 18, 2022
dba0d23
useObserver: handle re-render without reaction
urugator Dec 31, 2022
2316e04
disposeOnUnmount jsdoc decprecation msg
urugator Dec 31, 2022
dd52381
disposeOnUnmount refactor
urugator Dec 31, 2022
df38953
Merge remote-tracking branch 'upstream/main' into use-external-sync-s…
urugator Jan 14, 2023
9e09fab
cleanup
urugator Feb 11, 2023
f239cb4
improve displayName/name handling
urugator Feb 11, 2023
c1f375a
fix mobx-react test
urugator Feb 11, 2023
c0ececc
try provide GC bit more time
urugator Feb 11, 2023
829dab9
try lower GC time
urugator Feb 11, 2023
0ba9985
bit more perhpas?
urugator Feb 11, 2023
8c09317
300ms
urugator Feb 11, 2023
e5492e6
document magic number
urugator Feb 11, 2023
b250f58
use number instead of Symbol as stateVersion
urugator Feb 18, 2023
e6c6246
GC 300ms -> 500ms
urugator Feb 18, 2023
b41d71e
Merge remote-tracking branch 'upstream/main' into use-external-sync-s…
urugator Mar 12, 2023
b963f7a
fix api test
urugator Mar 12, 2023
344c2cf
move use-sync-external-store to dev deps
urugator Mar 12, 2023
98940ad
changeset
urugator Mar 12, 2023
4ebb124
bump deps
urugator Mar 12, 2023
1c7e16c
deps?
urugator Mar 12, 2023
4b138b5
deps?
urugator Mar 12, 2023
21a7590
add new lines to changeset
urugator Mar 22, 2023
91e6c1a
remove test
urugator Mar 22, 2023
26fe732
Merge remote-tracking branch 'upstream/main' into use-external-sync-s…
urugator Mar 22, 2023
12ef4cc
improve comment
urugator Mar 22, 2023
98cde8b
make finalization regsitry test bit more robust
urugator Mar 22, 2023
e55c51c
increase timeout
urugator Mar 22, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brown-seals-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"mobx": minor
---

Better support for React 18: Mobx now keeps track of a global state version, which updates with each mutation.
6 changes: 6 additions & 0 deletions .changeset/early-terms-bow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"mobx-react-lite": major
---

Components now use `useSyncExternalStore`, which should prevent tearing - you have to update mobx, otherwise it should behave as previously.<br>
Improved displayName/name handling as discussed in #3438.<br>
12 changes: 12 additions & 0 deletions .changeset/wise-waves-jam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"mobx-react": major
---

Functional components now use `useSyncExternalStore`, which should prevent tearing - you have to update mobx, otherwise it should behave as previously.<br>
Improved displayName/name handling of functional components as discussed in #3438.<br>
Reactions of uncommited class components are now correctly disposed, fixes #3492.<br>
Reactions don't notify uncommited class components, avoiding the warning, fixes #3492.<br>
Removed symbol "polyfill" and replaced with actual Symbols.<br>
Removed `this.render` replacement detection + warning. `this.render` is no longer configurable/writable (possibly BC).<br>
Class component instance is no longer exposed as `component[$mobx]["reactcomponent"]` (possibly BC).<br>
Deprecated `disposeOnUnmount`, it's not compatible with remounting.<br>
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
{
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.formatOnPaste": false
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.formatOnPaste": false
},
Expand Down
3 changes: 2 additions & 1 deletion packages/mobx-react-lite/__tests__/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ test("correct api should be exposed", function () {
"useObserver",
"isObserverBatched",
"observerBatching",
"useStaticRendering"
"useStaticRendering",
"_observerFinalizationRegistry"
].sort()
)
})
11 changes: 7 additions & 4 deletions packages/mobx-react-lite/__tests__/observer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -618,12 +618,15 @@ it("should hoist known statics only", () => {
expect(wrapped.render).toBe(undefined)
})

it("should have the correct displayName", () => {
const TestComponent = observer(function MyComponent() {
it("should inherit original name/displayName #3438", () => {
function Name() {
return null
})
}
Name.displayName = "DisplayName"
const TestComponent = observer(Name)

expect((TestComponent as any).type.displayName).toBe("MyComponent")
expect((TestComponent as any).type.name).toBe("Name")
expect((TestComponent as any).type.displayName).toBe("DisplayName")
})

test("parent / childs render in the right order", done => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cleanup, render } from "@testing-library/react"
import { cleanup, render, waitFor } from "@testing-library/react"
import * as mobx from "mobx"
import * as React from "react"
import { useObserver } from "../src/useObserver"
Expand Down Expand Up @@ -43,10 +43,17 @@ test("uncommitted components should not leak observations", async () => {

// Allow gc to kick in in case to let finalization registry cleanup
gc()
await sleep(50)

// count1 should still be being observed by Component1,
// but count2 should have had its reaction cleaned up.
expect(count1IsObserved).toBeTruthy()
expect(count2IsObserved).toBeFalsy()
// Can take a while (especially on CI) before gc actually calls the registry
await waitFor(
() => {
// count1 should still be being observed by Component1,
// but count2 should have had its reaction cleaned up.
expect(count1IsObserved).toBeTruthy()
expect(count2IsObserved).toBeFalsy()
},
{
timeout: 2000,
interval: 200
}
)
})
5 changes: 3 additions & 2 deletions packages/mobx-react-lite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"homepage": "https://mobx.js.org",
"dependencies": {},
"peerDependencies": {
"mobx": "^6.1.0",
"mobx": "^6.9.0",
"react": "^16.8.0 || ^17 || ^18"
},
"peerDependenciesMeta": {
Expand All @@ -51,7 +51,8 @@
},
"devDependencies": {
"mobx": "^6.8.0",
"expose-gc": "^1.0.0"
"expose-gc": "^1.0.0",
"use-sync-external-store": "^1.2.0"
},
"keywords": [
"mobx",
Expand Down
1 change: 1 addition & 0 deletions packages/mobx-react-lite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export { useLocalObservable } from "./useLocalObservable"
export { useLocalStore } from "./useLocalStore"
export { useAsObservableSource } from "./useAsObservableSource"

export { observerFinalizationRegistry as _observerFinalizationRegistry }
export const clearTimers = observerFinalizationRegistry["finalizeAllImmediately"] ?? (() => {})

export function useObserver<T>(fn: () => T, baseComponentName: string = "observed"): T {
Expand Down
14 changes: 8 additions & 6 deletions packages/mobx-react-lite/src/observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,13 @@ export function observer<P extends object, TRef = {}>(
return useObserver(() => render(props, ref), baseComponentName)
}

// Don't set `displayName` for anonymous components,
// so the `displayName` can be customized by user, see #3192.
if (baseComponentName !== "") {
;(observerComponent as React.FunctionComponent).displayName = baseComponentName
}
// Inherit original name and displayName, see #3438
;(observerComponent as React.FunctionComponent).displayName = baseComponent.displayName
Object.defineProperty(observerComponent, "name", {
value: baseComponent.name,
writable: true,
configurable: true
})

// Support legacy context: `contextTypes` must be applied before `memo`
if ((baseComponent as any).contextTypes) {
Expand Down Expand Up @@ -136,7 +138,7 @@ export function observer<P extends object, TRef = {}>(
set() {
throw new Error(
`[mobx-react-lite] \`${
this.displayName || this.type?.displayName || "Component"
this.displayName || this.type?.displayName || this.type?.name || "Component"
}.contextTypes\` must be set before applying \`observer\`.`
)
}
Expand Down
174 changes: 80 additions & 94 deletions packages/mobx-react-lite/src/useObserver.ts
Original file line number Diff line number Diff line change
@@ -1,129 +1,115 @@
import { Reaction } from "mobx"
import { Reaction, _getGlobalState } from "mobx"
import React from "react"
import { printDebugValue } from "./utils/printDebugValue"
import { observerFinalizationRegistry } from "./utils/observerFinalizationRegistry"
import { isUsingStaticRendering } from "./staticRendering"
import { observerFinalizationRegistry } from "./utils/observerFinalizationRegistry"
import { useSyncExternalStore } from "use-sync-external-store/shim"

function observerComponentNameFor(baseComponentName: string) {
return `observer${baseComponentName}`
}

// Do not store `admRef` (even as part of a closure!) on this object,
// otherwise it will prevent GC and therefore reaction disposal via FinalizationRegistry.
type ObserverAdministration = {
/** The Reaction created during first render, which may be leaked */
reaction: Reaction | null

/**
* Whether the component has yet completed mounting (for us, whether
* its useEffect has run)
*/
mounted: boolean

/**
* Whether the observables that the component is tracking changed between
* the first render and the first useEffect.
*/
changedBeforeMount: boolean
reaction: Reaction | null // also serves as disposed flag
forceUpdate: Function | null // also serves as mounted flag
// BC: we will use local state version if global isn't available.
// It should behave as previous implementation - tearing is still present,
// because there is no cross component synchronization,
// but we can use `useSyncExternalStore` API.
stateVersion: any
name: string
// These don't depend on state/props, therefore we can keep them here instead of `useCallback`
subscribe: Parameters<typeof React.useSyncExternalStore>[0]
getSnapshot: Parameters<typeof React.useSyncExternalStore>[1]
}

/**
* We use class to make it easier to detect in heap snapshots by name
*/
class ObjectToBeRetainedByReact {}
const mobxGlobalState = _getGlobalState()

// BC
const globalStateVersionIsAvailable = typeof mobxGlobalState.globalVersion !== "undefined"

function objectToBeRetainedByReactFactory() {
return new ObjectToBeRetainedByReact()
function createReaction(adm: ObserverAdministration) {
adm.reaction = new Reaction(`observer${adm.name}`, () => {
if (!globalStateVersionIsAvailable) {
// BC
adm.stateVersion = Symbol()
}
// Force update won't be avaliable until the component "mounts".
// If state changes in between initial render and mount,
// `useSyncExternalStore` should handle that by checking the state version and issuing update.
adm.forceUpdate?.()
})
}

export function useObserver<T>(fn: () => T, baseComponentName: string = "observed"): T {
export function useObserver<T>(render: () => T, baseComponentName: string = "observed"): T {
if (isUsingStaticRendering()) {
return fn()
return render()
}

const [objectRetainedByReact] = React.useState(objectToBeRetainedByReactFactory)
// Force update, see #2982
const [, setState] = React.useState()
const forceUpdate = () => setState([] as any)

// StrictMode/ConcurrentMode/Suspense may mean that our component is
// rendered and abandoned multiple times, so we need to track leaked
// Reactions.
const admRef = React.useRef<ObserverAdministration | null>(null)

if (!admRef.current) {
// First render
admRef.current = {
const adm: ObserverAdministration = {
reaction: null,
mounted: false,
changedBeforeMount: false
forceUpdate: null,
stateVersion: Symbol(),
name: baseComponentName,
subscribe(onStoreChange: () => void) {
// Do NOT access admRef here!
observerFinalizationRegistry.unregister(adm)
adm.forceUpdate = onStoreChange
if (!adm.reaction) {
// We've lost our reaction and therefore all subscriptions.
// We have to recreate reaction and schedule re-render to recreate subscriptions,
// even if state did not change.
createReaction(adm)
adm.forceUpdate()
}

return () => {
// Do NOT access admRef here!
adm.forceUpdate = null
adm.reaction?.dispose()
adm.reaction = null
}
},
getSnapshot() {
// Do NOT access admRef here!
return globalStateVersionIsAvailable
? mobxGlobalState.stateVersion
: adm.stateVersion
}
}

admRef.current = adm
}

const adm = admRef.current!

if (!adm.reaction) {
// First render or component was not committed and reaction was disposed by registry
adm.reaction = new Reaction(observerComponentNameFor(baseComponentName), () => {
// Observable has changed, meaning we want to re-render
// BUT if we're a component that hasn't yet got to the useEffect()
// stage, we might be a component that _started_ to render, but
// got dropped, and we don't want to make state changes then.
// (It triggers warnings in StrictMode, for a start.)
if (adm.mounted) {
// We have reached useEffect(), so we're mounted, and can trigger an update
forceUpdate()
} else {
// We haven't yet reached useEffect(), so we'll need to trigger a re-render
// when (and if) useEffect() arrives.
adm.changedBeforeMount = true
}
})

observerFinalizationRegistry.register(objectRetainedByReact, adm, adm)
// First render or reaction was disposed by registry before subscribe
createReaction(adm)
// StrictMode/ConcurrentMode/Suspense may mean that our component is
// rendered and abandoned multiple times, so we need to track leaked
// Reactions.
observerFinalizationRegistry.register(admRef, adm, adm)
}

React.useDebugValue(adm.reaction, printDebugValue)

React.useEffect(() => {
observerFinalizationRegistry.unregister(adm)

adm.mounted = true
React.useDebugValue(adm.reaction!, printDebugValue)

if (adm.reaction) {
if (adm.changedBeforeMount) {
// Got a change before mount, force an update
adm.changedBeforeMount = false
forceUpdate()
}
} else {
// The reaction we set up in our render has been disposed.
// This can be due to bad timings of renderings, e.g. our
// component was paused for a _very_ long time, and our
// reaction got cleaned up

// Re-create the reaction
adm.reaction = new Reaction(observerComponentNameFor(baseComponentName), () => {
// We've definitely already been mounted at this point
forceUpdate()
})
forceUpdate()
}

return () => {
adm.reaction!.dispose()
adm.reaction = null
adm.mounted = false
adm.changedBeforeMount = false
}
}, [])
useSyncExternalStore(
// Both of these must be stable, otherwise it would keep resubscribing every render.
adm.subscribe,
adm.getSnapshot
)

// render the original component, but have the
// reaction track the observables, so that rendering
// can be invalidated (see above) once a dependency changes
let rendering!: T
let renderResult!: T
let exception
adm.reaction.track(() => {
adm.reaction!.track(() => {
try {
rendering = fn()
renderResult = render()
} catch (e) {
exception = e
}
Expand All @@ -133,5 +119,5 @@ export function useObserver<T>(fn: () => T, baseComponentName: string = "observe
throw exception // re-throw any exceptions caught during rendering
}

return rendering
return renderResult
}
Loading