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

[v9] fix!: upgrade reconciler for React 19 #3224

Merged
merged 31 commits into from
Apr 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
995b1e2
fix!: upgrade reconciler for React 19
CodyJasonBennett Mar 27, 2024
a5d9b8a
fix: upgrade scheduler, use stable act
CodyJasonBennett Mar 27, 2024
2619ee6
experiment: remove context bridge to isolate hostcontext issue
CodyJasonBennett Mar 27, 2024
056349a
chore: cleanup
CodyJasonBennett Mar 28, 2024
f490c2e
chore: upgrade to canary
CodyJasonBennett Mar 31, 2024
c0f291c
fix: catch null in commitUpdate from prepareUpdate
CodyJasonBennett Mar 31, 2024
bf33941
chore(RTTR): update snapshot
CodyJasonBennett Mar 31, 2024
0e7597f
chore: upgrade canary
CodyJasonBennett Apr 6, 2024
03d7828
chore: update canary, implement updatePriority tracking
CodyJasonBennett Apr 9, 2024
6f5cdba
chore: filter out hostcontext warnings
CodyJasonBennett Apr 15, 2024
4d8d582
chore: update canary
CodyJasonBennett Apr 20, 2024
b28b1aa
chore: revert snapshot
CodyJasonBennett Apr 20, 2024
4559147
fix(reconciler): use empty host context
CodyJasonBennett Apr 22, 2024
cedf250
chore: update canary
CodyJasonBennett Apr 22, 2024
c147c8e
chore: cleanup
CodyJasonBennett Apr 22, 2024
dbed854
fix(reconciler): return null waitForCommitToBeReady
CodyJasonBennett Apr 22, 2024
1400fac
fix(renderer): use new container signature
CodyJasonBennett Apr 22, 2024
86dbead
Merge branch 'v9' into fix/upgrade-react19
CodyJasonBennett Apr 22, 2024
a9bd79e
Merge branch 'v9' into fix/upgrade-react19
CodyJasonBennett Apr 22, 2024
6b91dfe
chore(examples): use React 19
CodyJasonBennett Apr 22, 2024
2b15a91
chore(tests): restore scheduler mock
CodyJasonBennett Apr 23, 2024
7df9759
chore: update canary
CodyJasonBennett Apr 25, 2024
b5ef3ac
experiment(CI): show verbose test results
CodyJasonBennett Apr 25, 2024
e9460ed
experiment(CI): debug test logs
CodyJasonBennett Apr 25, 2024
15074b8
fix(reconciler): remove prepareUpdate, diff in commit
CodyJasonBennett Apr 25, 2024
65be1bb
chore(tests): cleanup test-renderer deprecations
CodyJasonBennett Apr 25, 2024
cdb6826
chore(CI): revert changes
CodyJasonBennett Apr 25, 2024
09118bc
fix(reconciler): sync dispose in test environment
CodyJasonBennett Apr 25, 2024
8ae3e1a
chore(tests): harden error cases
CodyJasonBennett Apr 25, 2024
04b88d1
chore(reconciler): cleanup
CodyJasonBennett Apr 25, 2024
9a913a6
chore: upgrade to beta
CodyJasonBennett Apr 25, 2024
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
4 changes: 2 additions & 2 deletions example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"@react-spring/three": "^9.7.3",
"@react-three/drei": "^9.93.0",
"@use-gesture/react": "latest",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "19.0.0-canary-db913d8e17-20240422",
"react-dom": "19.0.0-canary-db913d8e17-20240422",
"react-merge-refs": "^2.1.1",
"react-use-refs": "^1.0.1",
"three": "^0.160.0",
Expand Down
16 changes: 8 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,13 @@
"@changesets/changelog-git": "^0.1.11",
"@changesets/cli": "^2.22.0",
"@preconstruct/cli": "^2.1.5",
"@testing-library/react": "^13.0.0-alpha.5",
"@testing-library/react": "^15.0.2",
"@types/jest": "^29.2.5",
"@types/react": "^18.0.5",
"@types/react-dom": "^18.0.1",
"@types/react": "18.2.73",
"@types/react-dom": "18.2.22",
"@types/react-native": "0.69.5",
"@types/react-test-renderer": "^17.0.1",
"@types/scheduler": "^0.16.2",
"@types/react-test-renderer": "18.0.7",
"@types/scheduler": "0.23.0",
"@types/three": "^0.141.0",
"@typescript-eslint/eslint-plugin": "^5.17.0",
"@typescript-eslint/parser": "^5.17.0",
Expand All @@ -76,10 +76,10 @@
"lint-staged": "^12.3.7",
"prettier": "^2.6.1",
"pretty-quick": "^3.1.3",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react": "19.0.0-beta-94eed63c49-20240425",
"react-dom": "19.0.0-beta-94eed63c49-20240425",
"react-native": "0.69.3",
"react-test-renderer": "^18.0.0",
"react-test-renderer": "19.0.0-beta-94eed63c49-20240425",
"regenerator-runtime": "^0.13.9",
"three": "^0.141.0",
"three-stdlib": "^2.13.0",
Expand Down
12 changes: 6 additions & 6 deletions packages/fiber/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@
},
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.26.7",
"@types/react-reconciler": "^0.28.8",
"base64-js": "^1.5.1",
"buffer": "^6.0.3",
"its-fine": "^1.0.6",
"react-reconciler": "^0.27.0",
"its-fine": "^1.2.5",
"react-reconciler": "0.31.0-beta-94eed63c49-20240425",
"react-use-measure": "^2.1.1",
"scheduler": "^0.21.0",
"scheduler": "0.25.0-beta-94eed63c49-20240425",
"suspend-react": "^0.1.3",
"zustand": "^4.1.2"
},
Expand All @@ -58,8 +58,8 @@
"expo-asset": ">=8.4",
"expo-gl": ">=11.0",
"expo-file-system": ">=11.0",
"react": ">=18.0",
"react-dom": ">=18.0",
"react": ">=19.0",
"react-dom": ">=19.0",
"react-native": ">=0.69",
"three": ">=0.141"
},
Expand Down
140 changes: 95 additions & 45 deletions packages/fiber/src/core/reconciler.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import * as THREE from 'three'
import * as React from 'react'
import Reconciler from 'react-reconciler'
import { ContinuousEventPriority, DiscreteEventPriority, DefaultEventPriority } from 'react-reconciler/constants'
import {
// @ts-ignore
NoEventPriority,
ContinuousEventPriority,
DiscreteEventPriority,
DefaultEventPriority,
} from 'react-reconciler/constants'
import { unstable_IdlePriority as idlePriority, unstable_scheduleCallback as scheduleCallback } from 'scheduler'
import {
diffProps,
Expand Down Expand Up @@ -73,7 +79,7 @@ interface HostConfig {
suspenseInstance: Instance
hydratableInstance: never
publicInstance: Instance['object']
hostContext: never
hostContext: {}
updatePayload: null | [true] | [false, Instance['props']]
childSet: never
timeoutHandle: number | undefined
Expand Down Expand Up @@ -238,13 +244,18 @@ function removeChild(
if (shouldDispose && child.type !== 'primitive' && child.object.type !== 'Scene') {
if (typeof child.object.dispose === 'function') {
const dispose = child.object.dispose.bind(child.object)
scheduleCallback(idlePriority, () => {
const handleDispose = () => {
try {
dispose()
} catch (e) {
/* ... */
// no-op
}
})
}

// In a testing environment, cleanup immediately
if (typeof IS_REACT_ACT_ENVIRONMENT !== 'undefined') handleDispose()
// Otherwise, using a real GPU so schedule cleanup to prevent stalls
else scheduleCallback(idlePriority, handleDispose)
}
}

Expand Down Expand Up @@ -309,6 +320,37 @@ function switchInstance(
const handleTextInstance = () =>
console.warn('R3F: Text is not allowed in JSX! This could be stray whitespace or characters.')

const NO_CONTEXT: HostConfig['hostContext'] = {}

let currentUpdatePriority: number = NoEventPriority

// Effectively removed to diff in commit phase
// https://github.com/facebook/react/pull/27409
function prepareUpdate(
instance: HostConfig['instance'],
_type: string,
oldProps: HostConfig['props'],
newProps: HostConfig['props'],
): HostConfig['updatePayload'] {
// Reconstruct primitives if object prop changes
if (instance.type === 'primitive' && oldProps.object !== newProps.object) return [true]

// Throw if an object or literal was passed for args
if (newProps.args !== undefined && !Array.isArray(newProps.args))
throw new Error('R3F: The args prop must be an array!')

// Reconstruct instance if args change
if (newProps.args?.length !== oldProps.args?.length) return [true]
if (newProps.args?.some((value, index) => value !== oldProps.args?.[index])) return [true]

// Create a diff-set, flag if there are any changes
const changedProps = diffProps(instance, newProps, true)
if (Object.keys(changedProps).length) return [false, changedProps]

// Otherwise do not touch the instance
return null
}

export const reconciler = Reconciler<
HostConfig['type'],
HostConfig['props'],
Expand All @@ -324,11 +366,11 @@ export const reconciler = Reconciler<
HostConfig['timeoutHandle'],
HostConfig['noTimeout']
>({
supportsMutation: true,
isPrimaryRenderer: false,
warnsIfNotActing: false,
supportsMutation: true,
supportsPersistence: false,
supportsHydration: false,
noTimeout: -1,
createInstance,
removeChild,
appendChild,
Expand All @@ -352,29 +394,20 @@ export const reconciler = Reconciler<

insertBefore(scene, child, beforeChild)
},
getRootHostContext: () => null,
getChildHostContext: (parentHostContext) => parentHostContext,
prepareUpdate(instance, _type, oldProps, newProps) {
// Reconstruct primitives if object prop changes
if (instance.type === 'primitive' && oldProps.object !== newProps.object) return [true]

// Throw if an object or literal was passed for args
if (newProps.args !== undefined && !Array.isArray(newProps.args))
throw new Error('R3F: The args prop must be an array!')

// Reconstruct instance if args change
if (newProps.args?.length !== oldProps.args?.length) return [true]
if (newProps.args?.some((value, index) => value !== oldProps.args?.[index])) return [true]

// Create a diff-set, flag if there are any changes
const changedProps = diffProps(instance, newProps, true)
if (Object.keys(changedProps).length) return [false, changedProps]

// Otherwise do not touch the instance
return null
},
commitUpdate(instance, diff, type, _oldProps, newProps, fiber) {
const [reconstruct, changedProps] = diff!
getRootHostContext: () => NO_CONTEXT,
getChildHostContext: () => NO_CONTEXT,
// @ts-ignore prepareUpdate and updatePayload removed with React 19
commitUpdate(
instance: HostConfig['instance'],
type: HostConfig['type'],
oldProps: HostConfig['props'],
newProps: HostConfig['props'],
fiber: any,
) {
const diff = prepareUpdate(instance, type, oldProps, newProps)
if (diff === null) return

const [reconstruct, changedProps] = diff

// Reconstruct when args or <primitive object={...} have changes
if (reconstruct) return switchInstance(instance, type, newProps, fiber)
Expand Down Expand Up @@ -417,23 +450,40 @@ export const reconciler = Reconciler<
hideTextInstance: handleTextInstance,
unhideTextInstance: handleTextInstance,
// SSR fallbacks
now:
typeof performance !== 'undefined' && typeof performance.now === 'function'
? performance.now
: typeof Date.now === 'function'
? Date.now
: () => 0,
scheduleTimeout: (typeof setTimeout === 'function' ? setTimeout : undefined) as any,
cancelTimeout: (typeof clearTimeout === 'function' ? clearTimeout : undefined) as any,
// @ts-ignore Deprecated experimental APIs
// https://github.com/facebook/react/blob/main/packages/shared/ReactFeatureFlags.js
// https://github.com/pmndrs/react-three-fiber/pull/2360#discussion_r916356874
beforeActiveInstanceBlur: () => {},
afterActiveInstanceBlur: () => {},
detachDeletedInstance: () => {},
// Gives React a clue as to how import the current interaction is
// https://github.com/facebook/react/tree/main/packages/react-reconciler#getcurrenteventpriority
getCurrentEventPriority() {
noTimeout: -1,
getInstanceFromNode: () => null,
beforeActiveInstanceBlur() {},
afterActiveInstanceBlur() {},
detachDeletedInstance() {},
// @ts-ignore untyped react-experimental options inspired by react-art
// TODO: add shell types for these and upstream to DefinitelyTyped
// https://github.com/facebook/react/blob/main/packages/react-art/src/ReactFiberConfigART.js
shouldAttemptEagerTransition() {
return false
},
requestPostPaintCallback() {},
maySuspendCommit() {
return false
},
preloadInstance() {
return true // true indicates already loaded
},
startSuspendingCommit() {},
suspendInstance() {},
waitForCommitToBeReady() {
return null
},
NotPendingTransition: null,
setCurrentUpdatePriority(newPriority: number) {
currentUpdatePriority = newPriority
},
getCurrentUpdatePriority() {
return currentUpdatePriority
},
resolveUpdatePriority() {
if (currentUpdatePriority) return currentUpdatePriority
if (!globalScope) return DefaultEventPriority

const name = globalScope.event?.type
Expand Down
14 changes: 13 additions & 1 deletion packages/fiber/src/core/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,19 @@ export function createRoot<TCanvas extends Canvas>(canvas: TCanvas): ReconcilerR
const store = prevStore || createStore(invalidate, advance)
// Create renderer
const fiber =
prevFiber || reconciler.createContainer(store, ConcurrentRoot, null, false, null, '', logRecoverableError, null)
prevFiber ||
(reconciler as any).createContainer(
store, // container
ConcurrentRoot, // tag
null, // hydration callbacks
false, // isStrictMode
null, // concurrentUpdatesByDefaultOverride
'', // identifierPrefix
logRecoverableError, // onUncaughtError
logRecoverableError, // onCaughtError
logRecoverableError, // onRecoverableError
null, // transitionCallbacks
)
// Map it
if (!prevRoot) _roots.set(canvas, { fiber, store })

Expand Down
2 changes: 1 addition & 1 deletion packages/fiber/src/core/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export type Act = <T = any>(cb: () => Promise<T>) => Promise<T>
/**
* Safely flush async effects when testing, simulating a legacy root.
*/
export const act: Act = (React as any).unstable_act
export const act: Act = (React as any).act

export type Camera = (THREE.OrthographicCamera | THREE.PerspectiveCamera) & { manual?: boolean }
export const isOrthographicCamera = (def: Camera): def is THREE.OrthographicCamera =>
Expand Down
2 changes: 1 addition & 1 deletion packages/fiber/tests/canvas.native.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,6 @@ describe('native Canvas', () => {
),
)

expect(() => renderer.unmount()).not.toThrow()
expect(async () => await act(async () => renderer.unmount())).not.toThrow()
})
})
42 changes: 18 additions & 24 deletions packages/fiber/tests/renderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,14 @@ extend({ Mock })

type ComponentMesh = THREE.Mesh<THREE.BoxBufferGeometry, THREE.MeshBasicMaterial>

const expectToThrow = async (callback: () => any) => {
const error = console.error
console.error = jest.fn()

let thrown = false
const expectToThrow = async (callback: () => any, message: string) => {
let error: Error | undefined
try {
await callback()
} catch (_) {
thrown = true
} catch (e) {
error = e as Error
}

expect(thrown).toBe(true)
expect(console.error).toBeCalled()
console.error = error
expect(error?.message).toBe(message)
}

describe('renderer', () => {
Expand Down Expand Up @@ -256,8 +250,8 @@ describe('renderer', () => {

// Throw on non-array value
await expectToThrow(
// @ts-expect-error
async () => await act(async () => root.render(<Test args={{}} />)),
async () => await act(async () => root.render(<Test args={{} as any} />)),
'R3F: The args prop must be an array!',
)

// Set
Expand Down Expand Up @@ -316,8 +310,8 @@ describe('renderer', () => {

// Throw on undefined
await expectToThrow(
// @ts-expect-error
async () => await act(async () => root.render(<Test object={undefined} />)),
async () => await act(async () => root.render(<Test object={undefined as any} />)),
"R3F: Primitives without 'object' are invalid!",
)

// Update
Expand Down Expand Up @@ -397,21 +391,21 @@ describe('renderer', () => {
// Removes events
expect(internal.interaction.length).toBe(0)
// Calls dispose on top-level instance
expect(dispose).toBeCalled()
expect(dispose).toHaveBeenCalled()
// Also disposes of children
expect(childDispose).toBeCalled()
expect(childDispose).toHaveBeenCalled()
// Disposes of attached children
expect(attachDispose).toBeCalled()
expect(attachDispose).toHaveBeenCalled()
// Properly detaches attached children
expect(attach).toBeCalledTimes(1)
expect(detach).toBeCalledTimes(1)
expect(attach).toHaveBeenCalledTimes(1)
expect(detach).toHaveBeenCalledTimes(1)
// Respects dispose={null}
expect(flagDispose).not.toBeCalled()
expect(flagDispose).not.toHaveBeenCalled()
// Does not dispose of primitives
expect(object.dispose).not.toBeCalled()
expect(object.dispose).not.toHaveBeenCalled()
// Only disposes of declarative primitive children
expect(objectExternal.dispose).not.toBeCalled()
expect(disposeDeclarativePrimitive).toBeCalled()
expect(objectExternal.dispose).not.toHaveBeenCalled()
expect(disposeDeclarativePrimitive).toHaveBeenCalled()
})

it('can swap 4 array primitives', async () => {
Expand Down
Loading