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

experiment!: React 19 #81

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 9 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,29 +46,29 @@
"jest-environment-jsdom": "^28.1.3",
"ogl": "^1.0.3",
"prettier": "^2.7.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "19.0.0-beta-4508873393-20240430",
"react-dom": "19.0.0-beta-4508873393-20240430",
"react-native": "^0.69.4",
"react-test-renderer": "^18.2.0",
"react-test-renderer": "19.0.0-beta-4508873393-20240430",
"rimraf": "^3.0.2",
"typescript": "^4.7.4",
"vite": "^3.0.9"
},
"dependencies": {
"@types/react-reconciler": "^0.26.7",
"@types/react-reconciler": "^0.28.8",
"@types/webxr": "*",
"its-fine": "^1.1.1",
"react-reconciler": "^0.27.0",
"its-fine": "^1.2.5",
"react-reconciler": "0.31.0-beta-4508873393-20240430",
"react-use-measure": "^2.1.1",
"scheduler": "^0.23.0",
"scheduler": "0.25.0-beta-4508873393-20240430",
"suspend-react": "^0.1.3",
"zustand": "^4.5.2"
},
"peerDependencies": {
"expo-gl": ">=11.4",
"ogl": ">=1",
"react": ">=18.0",
"react-dom": ">=18.0",
"react": ">=19.0",
"react-dom": ">=19.0",
"react-native": ">=0.69"
},
"peerDependenciesMeta": {
Expand Down
117 changes: 74 additions & 43 deletions src/reconciler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Reconciler from 'react-reconciler'
import { DefaultEventPriority } from 'react-reconciler/constants.js'
// @ts-ignore
import { NoEventPriority, DefaultEventPriority } from 'react-reconciler/constants.js'
import { unstable_IdlePriority as idlePriority, unstable_scheduleCallback as scheduleCallback } from 'scheduler'
import * as OGL from 'ogl'
import * as React from 'react'
Expand Down Expand Up @@ -202,6 +203,9 @@ function switchInstance(
props: Instance['props'],
fiber: Reconciler.Fiber,
) {
// React 19 regression from (un)hide hooks
oldInstance.object.visible = true

// Create a new instance
const newInstance = createInstance(type, props, oldInstance.root)

Expand Down Expand Up @@ -281,6 +285,10 @@ function diffProps<T extends ConstructorRepresentation = any>(
return changedProps
}

const NO_CONTEXT = {}

let currentUpdatePriority: number = NoEventPriority

/**
* Centralizes and handles mutations through an OGL scene-graph.
*/
Expand All @@ -302,7 +310,7 @@ export const reconciler = Reconciler<
// Public (ref) instance
Instance['object'],
// Host context
null,
{},
// applyProps diff sets
null | [true] | [false, Instance['props']],
// Hydration child set
Expand Down Expand Up @@ -335,8 +343,8 @@ export const reconciler = Reconciler<
getPublicInstance: (instance) => instance.object,
// We can optionally access different host contexts on instance creation/update.
// Instances' data structures are self-sufficient, so we don't make use of this
getRootHostContext: () => null,
getChildHostContext: (parentHostContext) => parentHostContext,
getRootHostContext: () => NO_CONTEXT,
getChildHostContext: () => NO_CONTEXT,
// We can optionally mutate portal containers here, but we do that in createPortal instead from state
preparePortalMount: (container) => prepare(container.getState().scene, container, '', {}),
// This lets us store stuff at the container-level before/after React mutates our OGL elements.
Expand Down Expand Up @@ -374,56 +382,50 @@ export const reconciler = Reconciler<

removeChild(scene, child)
},
// Used to calculate updates in the render phase or commitUpdate.
// Greatly improves performance by reducing paint to rapid mutations.
prepareUpdate(instance, type, oldProps, newProps) {
// Element is a primitive. We must recreate it when its object prop is changed
if (instance.type === 'primitive' && oldProps.object !== newProps.object) return [true]
// This is where we mutate OGL elements in the render phase
// @ts-ignore
commitUpdate(instance: Instance, type: Type, oldProps: Instance['props'], newProps: Instance['props'], fiber: any) {
let reconstruct = false

// Element is a primitive. We must recreate it when its object prop is changed
if (instance.type === 'primitive' && oldProps.object !== newProps.object) reconstruct = true
// Element is a program. Check whether its vertex or fragment props changed to recreate
if (type === 'program') {
if (oldProps.vertex !== newProps.vertex) return [true]
if (oldProps.fragment !== newProps.fragment) return [true]
else if (type === 'program') {
if (oldProps.vertex !== newProps.vertex) reconstruct = true
if (oldProps.fragment !== newProps.fragment) reconstruct = true
}

// Element is a geometry. Check whether its attribute props changed to recreate.
if (type === 'geometry') {
else if (type === 'geometry') {
for (const key in oldProps) {
const isAttribute = (oldProps[key] as OGL.Attribute)?.data || (newProps[key] as OGL.Attribute)?.data
if (isAttribute && oldProps[key] !== newProps[key]) return [true]
if (isAttribute && oldProps[key] !== newProps[key]) {
reconstruct = true
break
}
}
}

// If the instance has new args, recreate it
if (newProps.args?.length !== oldProps.args?.length) return [true]
if (newProps.args?.some((value, index) => value !== oldProps.args?.[index])) return [true]
else if (newProps.args?.length !== oldProps.args?.length) reconstruct = true
else if (newProps.args?.some((value, index) => value !== oldProps.args?.[index])) reconstruct = true

// If flagged for recreation, swap to a new instance.
if (reconstruct) return switchInstance(instance, type, newProps, fiber)

// Diff through props and flag with changes
const changedProps = diffProps(instance, newProps, oldProps)
if (Object.keys(changedProps).length) return [false, changedProps]

// No changes, don't update the instance
return null
},
// This is where we mutate OGL elements in the render phase
commitUpdate(instance, payload, type, oldProps, newProps, root) {
const [reconstruct, changedProps] = payload!

// If flagged for recreation, swap to a new instance.
if (reconstruct) return switchInstance(instance, type, newProps, root)
if (Object.keys(changedProps).length) {
// Handle attach update
if (changedProps?.attach) {
if (oldProps.attach) detach(instance.parent!, instance)
instance.props.attach = newProps.attach
if (newProps.attach) attach(instance.parent!, instance)
}

// Handle attach update
if (changedProps?.attach) {
if (oldProps.attach) detach(instance.parent!, instance)
instance.props.attach = newProps.attach
if (newProps.attach) attach(instance.parent!, instance)
// Update instance props
Object.assign(instance.props, changedProps)
// Apply changed props
applyProps(instance.object, changedProps)
}

// Update instance props
Object.assign(instance.props, changedProps)

// Apply changed props
applyProps(instance.object, changedProps)
},
// Methods to toggle instance visibility on demand.
// React uses this with React.Suspense to display fallback content
Expand All @@ -444,17 +446,46 @@ export const reconciler = Reconciler<
// Configures a callback once the tree is finalized after commit-effects are fired
finalizeInitialChildren: () => false,
commitMount() {},
// @ts-ignore
getCurrentEventPriority: () => DefaultEventPriority,
beforeActiveInstanceBlur: () => {},
afterActiveInstanceBlur: () => {},
detachDeletedInstance: () => {},
prepareScopeUpdate() {},
getInstanceFromScope: () => null,
// @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
setCurrentUpdatePriority(newPriority) {
currentUpdatePriority = newPriority
},
getCurrentUpdatePriority() {
return currentUpdatePriority
},
resolveUpdatePriority() {
return currentUpdatePriority || DefaultEventPriority
},
shouldAttemptEagerTransition() {
return false
},
requestPostPaintCallback() {},
maySuspendCommit() {
return false
},
preloadInstance() {
return true // true indicates already loaded
},
startSuspendingCommit() {},
suspendInstance() {},
waitForCommitToBeReady() {
return null
},
NotPendingTransition: null,
resetFormInstance() {},
})

/**
* Safely flush async effects when testing, simulating a legacy root.
*/
export const act: Act = (React as any)?.unstable_act ?? (React as any)?.act
export const act: Act = (React as any).act

// Inject renderer meta into devtools
const isProd = typeof process === 'undefined' || process.env?.['NODE_ENV'] === 'production'
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,9 @@ declare global {
interface IntrinsicElements extends OGLElements {}
}
}

declare module 'react' {
namespace JSX {
interface IntrinsicElements extends OGLElements {}
}
}
2 changes: 1 addition & 1 deletion tests/native.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,6 @@ describe('Canvas', () => {
)
})

expect(() => renderer.unmount()).not.toThrow()
expect(async () => await act(async () => renderer.unmount())).not.toThrow()
})
})
8 changes: 2 additions & 6 deletions tests/utils/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,12 @@ import WebGLRenderingContext from './WebGLRenderingContext'

declare global {
var IS_REACT_ACT_ENVIRONMENT: boolean
var IS_REACT_NATIVE_TEST_ENVIRONMENT: boolean // https://github.com/facebook/react/pull/28419
}

// Let React know that we'll be testing effectful components
global.IS_REACT_ACT_ENVIRONMENT = true

// Mock scheduler to test React features
jest.mock('scheduler', () => ({
...jest.requireActual('scheduler/unstable_mock'),
unstable_scheduleCallback: (_: any, callback: () => void) => callback(),
}))
global.IS_REACT_NATIVE_TEST_ENVIRONMENT = true // hide react-test-renderer warnings

// PointerEvent is not in JSDOM
// https://github.com/jsdom/jsdom/pull/2666#issuecomment-691216178
Expand Down
Loading
Loading