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 19 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
7 changes: 7 additions & 0 deletions .changeset/early-terms-bow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"mobx-react": minor
"mobx-react-lite": minor
"mobx": patch
---

TODO
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
2 changes: 2 additions & 0 deletions packages/eslint-plugin-mobx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ It's a bit opinionated and can lead to a lot of false positives depending on you
### mobx/no-anonymous-observer (deprecated)

_Deprecated in favor of [react/display-name](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/display-name.md) + [componentWrapperFunctions](https://github.com/jsx-eslint/eslint-plugin-react). Example of **.eslintrc**:_

```
{
"rules": {
Expand All @@ -97,6 +98,7 @@ _Deprecated in favor of [react/display-name](https://github.com/jsx-eslint/eslin
}
}
```

---

Forbids anonymous functions or classes as `observer` components.
Expand Down
4 changes: 3 additions & 1 deletion packages/mobx-react-lite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@
"url": "https://github.com/mobxjs/mobx/issues"
},
"homepage": "https://mobx.js.org",
"dependencies": {},
"dependencies": {
"use-sync-external-store": "^1.2.0"
urugator marked this conversation as resolved.
Show resolved Hide resolved
urugator marked this conversation as resolved.
Show resolved Hide resolved
},
"peerDependencies": {
"mobx": "^6.1.0",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

peer dependency probably has to be bumped to make sure globalState.stateVersion is available?

Copy link
Collaborator Author

@urugator urugator Dec 28, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh that is pretty neat actually! I'd still bump the peerDependency (as the warnings are often ignored), and make this change itself a major version, since it might affect semantics and spreads risk a bit?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is gonna be a major should it still be focused on React 18 support with otherwise minimal changes, or should this be an opportunity to introduce larger changes? (removing deprecated APIs, hooks, inject, options, cleanups, etc ...)

I assume the former.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can do the former; decorators are also around the corner, an I expect after that is a better moment to do a bigger clean up (e.g. legacy decorator support etc)

"react": "^16.8.0 || ^17 || ^18"
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 clearTimes = observerFinalizationRegistry["finalizeAllImmediately"] ?? (() => {})

export function useObserver<T>(fn: () => T, baseComponentName: string = "observed"): T {
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
}
20 changes: 3 additions & 17 deletions packages/mobx-react/__tests__/__snapshots__/observer.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ exports[`#797 - replacing this.render should trigger a warning 1`] = `
}
`;

exports[`#3492 should not cause warning by calling forceUpdate on uncommited components 1`] = `[MockFunction]`;

exports[`Redeclaring an existing observer component as an observer should log a warning 1`] = `
[MockFunction] {
"calls": Array [
Expand All @@ -35,23 +37,7 @@ exports[`Redeclaring an existing observer component as an observer should log a
}
`;

exports[`SSR works #3448 1`] = `
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was not supposed to warn. Warning was caused by calling disableStaticRendering before unmount in the test. Therefore componentWillUnmount used logic for non-static rendering.

[MockFunction] {
"calls": Array [
Array [
"The reactive render of an observer class component (TestCmp)
was overridden after MobX attached. This may result in a memory leak if the
overridden reactive render was not properly disposed.",
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
}
`;
exports[`SSR works #3448 1`] = `[MockFunction]`;

exports[`issue 12 1`] = `
<div>
Expand Down
88 changes: 88 additions & 0 deletions packages/mobx-react/__tests__/finalizationRegistry.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { cleanup, render, waitFor } from "@testing-library/react"
import * as mobx from "mobx"
import * as React from "react"

// @ts-ignore
import gc from "expose-gc/function"
import { observer } from "../src"

afterEach(cleanup)

function sleep(time: number) {
return new Promise<void>(res => {
setTimeout(res, time)
})
}

// TODO dunno why it's not available
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

project was missing a configuration file, please take a look #3620
or simply add the file manually.

declare class WeakRef<T> {
constructor(object: T)
deref(): T | undefined
}

test("should not prevent GC of uncomitted components", async () => {
expect(typeof globalThis.FinalizationRegistry).toBe("function")

// This specific setup causes first instance of A not being commited.
// This is checked by comparing constructor and componentDidMount invocation counts.
// There is no profound reason why that's the case, if you know a simpler or more robust setup
// feel free to change this.

const o = mobx.observable({ x: 0 })
let aConstructorCount = 0
let aMountCount = 0

let firstARef: WeakRef<React.Component>

@observer
class A extends React.Component<any> {
constructor(props) {
super(props)
if (aConstructorCount === 0) {
firstARef = new WeakRef(this)
}
aConstructorCount++
}
componentDidMount(): void {
aMountCount++
}
render() {
return (
<React.Suspense fallback="fallback">
<LazyB />
{o.x}
</React.Suspense>
)
}
}

class B extends React.Component {
render() {
return "B"
}
}

const LazyA = React.lazy(() => Promise.resolve({ default: A }))
const LazyB = React.lazy(() => Promise.resolve({ default: B }))

function App() {
return (
<React.Suspense fallback="fallback">
<LazyA />
</React.Suspense>
)
}

const { unmount, container } = render(<App />)

expect(container).toHaveTextContent("fallback")
await waitFor(() => expect(container).toHaveTextContent("B0"))
expect(aConstructorCount).toBe(2)
expect(aMountCount).toBe(1)

gc()
await sleep(50)
expect(firstARef!.deref()).toBeUndefined()

unmount()
})
Loading