Skip to content

Commit

Permalink
[v9] fix!: upgrade reconciler for React 19 (#3224)
Browse files Browse the repository at this point in the history
  • Loading branch information
CodyJasonBennett authored Apr 26, 2024
1 parent 6d2543b commit be6cc23
Show file tree
Hide file tree
Showing 12 changed files with 241 additions and 249 deletions.
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

0 comments on commit be6cc23

Please sign in to comment.