diff --git a/packages/fiber/src/core/index.tsx b/packages/fiber/src/core/index.tsx index 3ef9c42201..915de102aa 100644 --- a/packages/fiber/src/core/index.tsx +++ b/packages/fiber/src/core/index.tsx @@ -4,7 +4,18 @@ import { ConcurrentRoot } from 'react-reconciler/constants' import create, { UseBoundStore } from 'zustand' import * as ReactThreeFiber from '../three-types' -import { Renderer, createStore, isRenderer, context, RootState, Size, Dpr, Performance } from './store' +import { + Renderer, + createStore, + isRenderer, + context, + RootState, + Size, + Dpr, + Performance, + PrivateKeys, + privateKeys, +} from './store' import { createRenderer, extend, prepare, Root } from './renderer' import { createLoop, addEffect, addAfterEffect, addTail, flushGlobalEffects } from './loop' import { getEventPriority, EventManager, ComputeFunction } from './events' @@ -19,7 +30,6 @@ import { updateCamera, getColorManagement, hasColorSpace, - useMutableCallback, } from './utils' import { useStore } from './hooks' import type { Properties } from '../three-types' @@ -423,18 +433,19 @@ function unmountComponentAtNode(canvas: TCanvas, callbac } export type InjectState = Partial< - Omit & { + Omit & { events?: { enabled?: boolean priority?: number compute?: ComputeFunction connected?: any } + size?: Size } > function createPortal(children: React.ReactNode, container: THREE.Object3D, state?: InjectState): JSX.Element { - return + return } function Portal({ @@ -456,52 +467,90 @@ function Portal({ const [raycaster] = React.useState(() => new THREE.Raycaster()) const [pointer] = React.useState(() => new THREE.Vector2()) - const inject = useMutableCallback((rootState: RootState, injectState: RootState) => { - let viewport - if (injectState.camera && size) { - const camera = injectState.camera - // Calculate the override viewport, if present - viewport = rootState.viewport.getCurrentViewport(camera, new THREE.Vector3(), size) - // Update the portal camera, if it differs from the previous layer - if (camera !== rootState.camera) updateCamera(camera, size) - } + const inject = React.useCallback( + (rootState: RootState, injectState: RootState) => { + const intersect: Partial = { ...rootState } // all prev state props + + // Only the fields of "rootState" that do not differ from injectState + // Some props should be off-limits + // Otherwise filter out the props that are different and let the inject layer take precedence + Object.keys(rootState).forEach((key) => { + if ( + // Some props should be off-limits + privateKeys.includes(key as PrivateKeys) || + // Otherwise filter out the props that are different and let the inject layer take precedence + // Unless the inject layer props is undefined, then we keep the root layer + (rootState[key as keyof RootState] !== injectState[key as keyof RootState] && + injectState[key as keyof RootState]) + ) { + delete intersect[key as keyof RootState] + } + }) + + let viewport = undefined + if (injectState && size) { + const camera = injectState.camera + // Calculate the override viewport, if present + viewport = rootState.viewport.getCurrentViewport(camera, new THREE.Vector3(), size) + // Update the portal camera, if it differs from the previous layer + if (camera !== rootState.camera) updateCamera(camera, size) + } - return { - // The intersect consists of the previous root state - ...rootState, - get: injectState.get, - set: injectState.set, - // Portals have their own scene, which forms the root, a raycaster and a pointer + return { + // The intersect consists of the previous root state + ...intersect, + // Portals have their own scene, which forms the root, a raycaster and a pointer + scene: container as THREE.Scene, + raycaster, + pointer, + mouse: pointer, + // Their previous root is the layer before it + previousRoot, + // Events, size and viewport can be overridden by the inject layer + events: { ...rootState.events, ...injectState?.events, ...events }, + size: { ...rootState.size, ...size }, + viewport: { ...rootState.viewport, ...viewport }, + ...rest, + } as RootState + }, + [state], + ) + + const [usePortalStore] = React.useState(() => { + // Create a mirrored store, based on the previous root with a few overrides ... + const previousState = previousRoot.getState() + const store = create((set, get) => ({ + ...previousState, scene: container as THREE.Scene, raycaster, pointer, mouse: pointer, - // Their previous root is the layer before it previousRoot, - // Events, size and viewport can be overridden by the inject layer - events: { ...rootState.events, ...injectState.events, ...events }, - size: { ...rootState.size, ...size }, - viewport: { ...rootState.viewport, ...viewport }, + events: { ...previousState.events, ...events }, + size: { ...previousState.size, ...size }, + ...rest, + // Set and get refer to this root-state + set, + get, // Layers are allowed to override events setEvents: (events: Partial>) => - injectState.set((state) => ({ ...state, events: { ...state.events, ...events } })), - } as RootState + set((state) => ({ ...state, events: { ...state.events, ...events } })), + })) + return store }) - const usePortalStore = React.useMemo(() => { - const store = create((set, get) => ({ ...rest, set, get } as RootState)) - + React.useEffect(() => { // Subscribe to previous root-state and copy changes over to the mirrored portal-state - const onMutate = (prev: RootState) => store.setState((state) => inject.current(prev, state)) - onMutate(previousRoot.getState()) - previousRoot.subscribe(onMutate) + const unsub = previousRoot.subscribe((prev) => usePortalStore.setState((state) => inject(prev, state))) + return () => { + unsub() + usePortalStore.destroy() + } + }, []) - return store - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [previousRoot, container]) React.useEffect(() => { - return () => usePortalStore.destroy() - }, [usePortalStore]) + usePortalStore.setState((injectState) => inject(previousRoot.getState(), injectState)) + }, [inject]) return ( <> diff --git a/packages/fiber/src/core/store.ts b/packages/fiber/src/core/store.ts index 0ed95d688d..c392ad952f 100644 --- a/packages/fiber/src/core/store.ts +++ b/packages/fiber/src/core/store.ts @@ -5,6 +5,22 @@ import { prepare } from './renderer' import { DomEvent, EventManager, PointerCaptureTarget, ThreeEvent } from './events' import { calculateDpr, Camera, isOrthographicCamera, updateCamera } from './utils' +// Keys that shouldn't be copied between R3F stores +export const privateKeys = [ + 'set', + 'get', + 'setSize', + 'setFrameloop', + 'setDpr', + 'events', + 'invalidate', + 'advance', + 'size', + 'viewport', +] as const + +export type PrivateKeys = typeof privateKeys[number] + export interface Intersection extends THREE.Intersection { eventObject: THREE.Object3D } diff --git a/packages/fiber/tests/core/renderer.test.tsx b/packages/fiber/tests/core/renderer.test.tsx index 44bb5a3875..e24b19abfa 100644 --- a/packages/fiber/tests/core/renderer.test.tsx +++ b/packages/fiber/tests/core/renderer.test.tsx @@ -13,7 +13,7 @@ import { createPortal, } from '../../src/index' import { UseBoundStore } from 'zustand' -import { RootState } from '../../src/core/store' +import { privateKeys, RootState } from '../../src/core/store' import { Instance } from '../../src/core/renderer' type ComponentMesh = THREE.Mesh @@ -823,6 +823,11 @@ describe('renderer', () => { // Creates an isolated state enclave expect(state.scene).not.toBe(scene) expect(portalState.scene).toBe(scene) + + // Preserves internal keys + const overwrittenKeys = ['get', 'set', 'events', 'size', 'viewport'] + const respectedKeys = privateKeys.filter((key) => overwrittenKeys.includes(key) || state[key] === portalState[key]) + expect(respectedKeys).toStrictEqual(privateKeys) }) it('can handle createPortal on unmounted container', async () => {