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 15 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
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
import { cleanup, render } from "@testing-library/react"
import * as mobx from "mobx"
import * as React from "react"

import { useObserver } from "../src/useObserver"
import { sleep } from "./utils"
import { FinalizationRegistry } from "../src/utils/FinalizationRegistryWrapper"

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

if (typeof globalThis.FinalizationRegistry !== "function") {
throw new Error("This test must run with node >= 14")
}

expect(observerFinalizationRegistry).toBeInstanceOf(globalThis.FinalizationRegistry)

afterEach(cleanup)

test("uncommitted components should not leak observations", async () => {
if (!FinalizationRegistry) {
throw new Error("This test must run with node >= 14")
}

const store = mobx.observable({ count1: 0, count2: 0 })

// Track whether counts are observed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,22 @@ import "./utils/killFinalizationRegistry"
import { act, cleanup, render } from "@testing-library/react"
import * as mobx from "mobx"
import * as React from "react"

import { useObserver } from "../src/useObserver"
import {
forceCleanupTimerToRunNowForTests,
resetCleanupScheduleForTests
} from "../src/utils/reactionCleanupTracking"
import {
CLEANUP_LEAKED_REACTIONS_AFTER_MILLIS,
CLEANUP_TIMER_LOOP_MILLIS
} from "../src/utils/reactionCleanupTrackingCommon"
REGISTRY_FINALIZE_AFTER,
REGISTRY_SWEEP_INTERVAL
} from "../src/utils/UniversalFinalizationRegistry"
import { observerFinalizationRegistry } from "../src/utils/observerFinalizationRegistry"
import { TimerBasedFinalizationRegistry } from "../src/utils/UniversalFinalizationRegistry"

expect(observerFinalizationRegistry).toBeInstanceOf(TimerBasedFinalizationRegistry)

const registry = observerFinalizationRegistry as TimerBasedFinalizationRegistry<unknown>

afterEach(cleanup)

test("uncommitted components should not leak observations", async () => {
resetCleanupScheduleForTests()
registry.finalizeAllImmediately()

// Unfortunately, Jest fake timers don't mock out Date.now, so we fake
// that out in parallel to Jest useFakeTimers
Expand Down Expand Up @@ -51,7 +52,7 @@ test("uncommitted components should not leak observations", async () => {
)

// Allow any reaction-disposal cleanup timers to run
const skip = Math.max(CLEANUP_LEAKED_REACTIONS_AFTER_MILLIS, CLEANUP_TIMER_LOOP_MILLIS)
const skip = Math.max(REGISTRY_FINALIZE_AFTER, REGISTRY_SWEEP_INTERVAL)
fakeNow += skip
jest.advanceTimersByTime(skip)

Expand All @@ -72,7 +73,7 @@ test("cleanup timer should not clean up recently-pended reactions", () => {
// 5. The commit phase runs for component A, but reaction R2 has already been disposed. Game over.

// This unit test attempts to replicate that scenario:
resetCleanupScheduleForTests()
registry.finalizeAllImmediately()

// Unfortunately, Jest fake timers don't mock out Date.now, so we fake
// that out in parallel to Jest useFakeTimers
Expand Down Expand Up @@ -106,7 +107,7 @@ test("cleanup timer should not clean up recently-pended reactions", () => {
// We force our cleanup loop to run even though enough time hasn't _really_
// elapsed. In theory, it won't do anything because not enough time has
// elapsed since the reactions were queued, and so they won't be disposed.
forceCleanupTimerToRunNowForTests()
registry.sweep()

// Advance time enough to allow any timer-queued effects to run
jest.advanceTimersByTime(500)
Expand Down Expand Up @@ -137,7 +138,7 @@ test.skip("component should recreate reaction if necessary", () => {

// This unit test attempts to replicate that scenario:

resetCleanupScheduleForTests()
registry.finalizeAllImmediately()

// Unfortunately, Jest fake timers don't mock out Date.now, so we fake
// that out in parallel to Jest useFakeTimers
Expand Down Expand Up @@ -166,9 +167,9 @@ test.skip("component should recreate reaction if necessary", () => {
// and _then_ the component commits.

// Force everything to be disposed.
const skip = Math.max(CLEANUP_LEAKED_REACTIONS_AFTER_MILLIS, CLEANUP_TIMER_LOOP_MILLIS)
const skip = Math.max(REGISTRY_FINALIZE_AFTER, REGISTRY_SWEEP_INTERVAL)
fakeNow += skip
forceCleanupTimerToRunNowForTests()
registry.sweep()

// The reaction should have been cleaned up.
expect(countIsObserved).toBeFalsy()
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
5 changes: 4 additions & 1 deletion packages/mobx-react-lite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { observerBatching } from "./utils/observerBatching"
import { useDeprecated } from "./utils/utils"
import { useObserver as useObserverOriginal } from "./useObserver"
import { enableStaticRendering } from "./staticRendering"
import { observerFinalizationRegistry } from "./utils/observerFinalizationRegistry"

observerBatching(batch)

Expand All @@ -14,7 +15,9 @@ export { Observer } from "./ObserverComponent"
export { useLocalObservable } from "./useLocalObservable"
export { useLocalStore } from "./useLocalStore"
export { useAsObservableSource } from "./useAsObservableSource"
export { resetCleanupScheduleForTests as clearTimers } from "./utils/reactionCleanupTracking"

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

export function useObserver<T>(fn: () => T, baseComponentName: string = "observed"): T {
if ("production" !== process.env.NODE_ENV) {
Expand Down
181 changes: 87 additions & 94 deletions packages/mobx-react-lite/src/useObserver.ts
Original file line number Diff line number Diff line change
@@ -1,118 +1,111 @@
import { Reaction } from "mobx"
import { Reaction, _getGlobalState } from "mobx"
import React from "react"
import { printDebugValue } from "./utils/printDebugValue"
import {
addReactionToTrack,
IReactionTracking,
recordReactionAsCommitted
} from "./utils/reactionCleanupTracking"
import { isUsingStaticRendering } from "./staticRendering"

function observerComponentNameFor(baseComponentName: string) {
return `observer${baseComponentName}`
import { observerFinalizationRegistry } from "./utils/observerFinalizationRegistry"
import { useSyncExternalStore } from "use-sync-external-store/shim"

// 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 = {
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 reactionTrackingRef = React.useRef<IReactionTracking | null>(null)

if (!reactionTrackingRef.current) {
// First render for this component (or first time since a previous
// reaction from an abandoned render was disposed).

const newReaction = 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 (trackingData.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.
trackingData.changedBeforeMount = true
const admRef = React.useRef<ObserverAdministration | null>(null)

if (!admRef.current) {
const adm: ObserverAdministration = {
reaction: null,
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
}
})
}

createReaction(adm)

const trackingData = addReactionToTrack(
reactionTrackingRef,
newReaction,
objectRetainedByReact
)
admRef.current = 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)
}

const { reaction } = reactionTrackingRef.current!
React.useDebugValue(reaction, printDebugValue)

React.useEffect(() => {
// Called on first mount only
recordReactionAsCommitted(reactionTrackingRef)

if (reactionTrackingRef.current) {
// Great. We've already got our reaction from our render;
// all we need to do is to record that it's now mounted,
// to allow future observable changes to trigger re-renders
reactionTrackingRef.current.mounted = true
// Got a change before first mount, force an update
if (reactionTrackingRef.current.changedBeforeMount) {
reactionTrackingRef.current.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
reactionTrackingRef.current = {
reaction: new Reaction(observerComponentNameFor(baseComponentName), () => {
// We've definitely already been mounted at this point
forceUpdate()
}),
mounted: true,
changedBeforeMount: false,
cleanAt: Infinity
}
forceUpdate()
}
const adm = admRef.current!
React.useDebugValue(adm.reaction!, printDebugValue)

return () => {
reactionTrackingRef.current!.reaction.dispose()
reactionTrackingRef.current = null
}
}, [])
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
reaction.track(() => {
adm.reaction!.track(() => {
try {
rendering = fn()
renderResult = render()
} catch (e) {
exception = e
}
Expand All @@ -122,5 +115,5 @@ export function useObserver<T>(fn: () => T, baseComponentName: string = "observe
throw exception // re-throw any exceptions caught during rendering
}

return rendering
return renderResult
}
12 changes: 0 additions & 12 deletions packages/mobx-react-lite/src/utils/FinalizationRegistryWrapper.ts

This file was deleted.

Loading