From 3060c8f60b930e607c180632d2ebbeb021db9ede Mon Sep 17 00:00:00 2001 From: Trezy Date: Thu, 27 Jun 2024 17:12:55 -0500 Subject: [PATCH] refactor: handle async application initialisation --- src/components/Application.js | 78 +++++++++++---- src/core/createRoot.js | 120 ++++++++++++++++++++++++ src/core/reconciler.js | 86 +++++++++++++++++ src/core/roots.js | 6 ++ src/helpers/applyProps.js | 13 +-- src/helpers/isReadOnlyProperty.js | 12 +++ src/helpers/prepareInstance.js | 3 +- src/hooks/useIsomorphicLayoutEffect.js | 9 ++ src/index.js | 2 +- src/reconciler.js | 86 ----------------- src/render.js | 125 ------------------------- src/typedefs/InternalState.js | 4 +- src/typedefs/Root.js | 8 ++ 13 files changed, 310 insertions(+), 242 deletions(-) create mode 100644 src/core/createRoot.js create mode 100644 src/core/reconciler.js create mode 100644 src/core/roots.js create mode 100644 src/helpers/isReadOnlyProperty.js create mode 100644 src/hooks/useIsomorphicLayoutEffect.js delete mode 100644 src/reconciler.js delete mode 100644 src/render.js create mode 100644 src/typedefs/Root.js diff --git a/src/components/Application.js b/src/components/Application.js index 929bc64a..cc1cd5f3 100644 --- a/src/components/Application.js +++ b/src/components/Application.js @@ -1,13 +1,14 @@ +import { Application as PixiApplication } from 'pixi.js'; import { createElement, forwardRef, - useEffect, + useCallback, useImperativeHandle, useRef, } from 'react'; -import { render } from '../render.js'; +import { createRoot } from '../core/createRoot.js'; +import { useIsomorphicLayoutEffect } from '../hooks/useIsomorphicLayoutEffect.js'; -/** @typedef {import('pixi.js').Application} PixiApplication */ /** @typedef {import('pixi.js').ApplicationOptions} PixiApplicationOptions */ /** * @template T @@ -27,6 +28,8 @@ import { render } from '../render.js'; * @typedef {import('../typedefs/OmitChildren.js').OmitChildren} OmitChildren */ +/** @typedef {import('../typedefs/Root.js').Root} Root */ + /** * @template T * @typedef {T extends undefined ? never : Omit} OmitResizeTo @@ -36,6 +39,7 @@ import { render } from '../render.js'; * @typedef {object} BaseApplicationProps * @property {boolean} [attachToDevTools] Whether this application chould be attached to the dev tools. NOTE: This should only be enabled on one application at a time. * @property {string} [className] CSS classes to be applied to the Pixi Application's canvas element. + * @property {(app: PixiApplication) => void} [onInit] Callback to be fired when the application finishes initializing. */ /** @typedef {{ resizeTo?: HTMLElement | Window | RefObject }} ResizeToProp */ @@ -55,6 +59,7 @@ export const ApplicationFunction = (props, forwardedRef) => attachToDevTools, children, className, + onInit, resizeTo, ...applicationProps } = props; @@ -62,48 +67,83 @@ export const ApplicationFunction = (props, forwardedRef) => /** @type {MutableRefObject} */ const applicationRef = useRef(null); - /** @type {RefObject} */ + /** @type {MutableRefObject} */ const canvasRef = useRef(null); - useImperativeHandle(forwardedRef, () => /** @type {PixiApplication} */ /** @type {*} */ (applicationRef.current)); + /** @type {MutableRefObject} */ + const rootRef = useRef(null); - useEffect(() => + useImperativeHandle(forwardedRef, () => { - const canvasElement = canvasRef.current; + /** @type {PixiApplication} */ + const typedApplication = /** @type {*} */ (applicationRef.current); - if (canvasElement) - { - /** @type {ApplicationProps} */ - const parsedApplicationProps = { - ...applicationProps, - }; + return typedApplication; + }); + const updateResizeTo = useCallback(() => + { + const application = applicationRef.current; + + if (application) + { if (resizeTo) { if ('current' in resizeTo) { if (resizeTo.current instanceof HTMLElement) { - parsedApplicationProps.resizeTo = resizeTo.current; + application.resizeTo = resizeTo.current; } } else { - ( - parsedApplicationProps.resizeTo = resizeTo - ); + application.resizeTo = resizeTo; } } + else + { + // @ts-expect-error Actually `resizeTo` is optional, the types are just wrong. 🤷🏻‍♂️ + delete application.resizeTo; + } + } + }, [resizeTo]); + + /** @type {(app: PixiApplication) => void} */ + const handleInit = useCallback((application) => + { + applicationRef.current = application; + updateResizeTo(); + onInit?.(application); + }, [onInit]); + + useIsomorphicLayoutEffect(() => + { + /** @type {HTMLCanvasElement} */ + const canvasElement = /** @type {*} */ (canvasRef.current); + + if (canvasElement) + { + if (!rootRef.current) + { + rootRef.current = createRoot(canvasElement, {}, handleInit); + } - applicationRef.current = render(children, canvasElement, parsedApplicationProps); + rootRef.current.render(children, applicationProps); } }, [ applicationProps, children, + handleInit, resizeTo, ]); - useEffect(() => + useIsomorphicLayoutEffect(() => + { + updateResizeTo(); + }, [resizeTo]); + + useIsomorphicLayoutEffect(() => { const application = applicationRef.current; diff --git a/src/core/createRoot.js b/src/core/createRoot.js new file mode 100644 index 00000000..638338bd --- /dev/null +++ b/src/core/createRoot.js @@ -0,0 +1,120 @@ +import { Application } from 'pixi.js'; +import { createElement } from 'react'; +import { ConcurrentRoot } from 'react-reconciler/constants.js'; +import { ContextProvider } from '../components/Context.js'; +import { isReadOnlyProperty } from '../helpers/isReadOnlyProperty.js'; +import { log } from '../helpers/log.js'; +import { prepareInstance } from '../helpers/prepareInstance.js'; +import { reconciler } from './reconciler.js'; +import { roots } from './roots.js'; + +/** @typedef {import('pixi.js').ApplicationOptions} ApplicationOptions */ + +/** @typedef {import('../typedefs/InternalState.js').InternalState} InternalState */ +/** @typedef {import('../typedefs/Root.js').Root} Root */ + +/** + * Creates a new root for a Pixi React app. + * + * @param {HTMLElement | HTMLCanvasElement} target The target element into which the Pixi application will be rendered. Can be any element, but if a is passed the application will be rendered to it directly. + * @param {Partial} [options] + * @param {(app: Application) => void} [onInit] Callback to be fired when the application finishes initializing. + * @returns {Root} + */ +export function createRoot(target, options = {}, onInit) +{ + // Check against mistaken use of createRoot + let root = roots.get(target); + + const state = /** @type {InternalState} */ (Object.assign((root?.state ?? {}), options)); + + if (root) + { + log('warn', 'createRoot should only be called once!'); + } + else + { + state.app = new Application(); + state.rootContainer = prepareInstance(state.app.stage); + } + + const fiber = root?.fiber ?? reconciler.createContainer( + state.rootContainer, + ConcurrentRoot, + null, + false, + null, + '', + console.error, + null, + ); + + if (!root) + { + let canvas; + + if (target instanceof HTMLCanvasElement) + { + canvas = target; + } + + if (!canvas) + { + canvas = document.createElement('canvas'); + target.innerHTML = ''; + target.appendChild(canvas); + } + + /** + * @param {import('react').ReactNode} children + * @param {ApplicationOptions} applicationOptions + * @returns {Promise} + */ + const render = async (children, applicationOptions) => + { + if (!state.app.renderer && !state.isInitialising) + { + state.isInitialising = true; + await state.app.init({ + ...applicationOptions, + canvas, + }); + onInit?.(state.app); + state.isInitialising = false; + } + + Object.entries(applicationOptions).forEach(([key, value]) => + { + const typedKey = /** @type {keyof ApplicationOptions} */ (key); + + if (isReadOnlyProperty(applicationOptions, typedKey)) + { + return; + } + + // @ts-expect-error Typescript doesn't realise it, but we're already verifying that this isn't a readonly key. + state.app[typedKey] = value; + }); + + // Update fiber and expose Pixi.js state to children + reconciler.updateContainer( + createElement(ContextProvider, { value: state }, children), + fiber, + null, + () => undefined + ); + + return state.app; + }; + + root = { + fiber, + render, + state, + }; + + roots.set(canvas, root); + } + + return root; +} diff --git a/src/core/reconciler.js b/src/core/reconciler.js new file mode 100644 index 00000000..16086b08 --- /dev/null +++ b/src/core/reconciler.js @@ -0,0 +1,86 @@ +/* eslint-disable no-empty-function */ + +import Reconciler from 'react-reconciler'; +import { afterActiveInstanceBlur } from '../helpers/afterActiveInstanceBlur.js'; +import { appendChild } from '../helpers/appendChild.js'; +import { beforeActiveInstanceBlur } from '../helpers/beforeActiveInstanceBlur.js'; +import { clearContainer } from '../helpers/clearContainer.js'; +import { commitUpdate } from '../helpers/commitUpdate.js'; +import { createInstance } from '../helpers/createInstance.js'; +import { createTextInstance } from '../helpers/createTextInstance.js'; +import { detachDeletedInstance } from '../helpers/detachDeletedInstance.js'; +import { finalizeInitialChildren } from '../helpers/finalizeInitialChildren.js'; +import { getChildHostContext } from '../helpers/getChildHostContext.js'; +import { getCurrentEventPriority } from '../helpers/getCurrentEventPriority.js'; +import { getInstanceFromNode } from '../helpers/getInstanceFromNode.js'; +import { getInstanceFromScope } from '../helpers/getInstanceFromScope.js'; +import { getPublicInstance } from '../helpers/getPublicInstance.js'; +import { getRootHostContext } from '../helpers/getRootHostContext.js'; +import { insertBefore } from '../helpers/insertBefore.js'; +import { prepareForCommit } from '../helpers/prepareForCommit.js'; +import { preparePortalMount } from '../helpers/preparePortalMount.js'; +import { prepareScopeUpdate } from '../helpers/prepareScopeUpdate.js'; +import { prepareUpdate } from '../helpers/prepareUpdate.js'; +import { removeChild } from '../helpers/removeChild.js'; +import { resetAfterCommit } from '../helpers/resetAfterCommit.js'; +import { shouldSetTextContent } from '../helpers/shouldSetTextContent.js'; + +/** @typedef {import('../typedefs/HostConfig.js').HostConfig} HostConfig */ +/** @typedef {import('../typedefs/Instance.js').Instance} Instance */ + +/** + * @type {Reconciler.HostConfig< + * HostConfig['type'], + * HostConfig['props'], + * HostConfig['container'], + * HostConfig['instance'], + * HostConfig['textInstance'], + * HostConfig['suspenseInstance'], + * HostConfig['hydratableInstance'], + * HostConfig['publicInstance'], + * HostConfig['hostContext'], + * HostConfig['updatePayload'], + * HostConfig['childSet'], + * HostConfig['timeoutHandle'], + * HostConfig['noTimeout'] + * >} + */ +const reconcilerConfig = { + isPrimaryRenderer: false, + noTimeout: -1, + supportsHydration: false, + supportsMutation: true, + supportsPersistence: false, + + afterActiveInstanceBlur, + appendChild, + appendChildToContainer: appendChild, + appendInitialChild: appendChild, + beforeActiveInstanceBlur, + cancelTimeout: clearTimeout, + clearContainer, + commitUpdate, + createInstance, + createTextInstance, + detachDeletedInstance, + finalizeInitialChildren, + getChildHostContext, + getCurrentEventPriority, + getInstanceFromNode, + getInstanceFromScope, + getPublicInstance, + getRootHostContext, + insertBefore, + insertInContainerBefore: insertBefore, + prepareForCommit, + preparePortalMount, + prepareScopeUpdate, + prepareUpdate, + removeChild, + removeChildFromContainer: removeChild, + resetAfterCommit, + scheduleTimeout: setTimeout, + shouldSetTextContent, +}; + +export const reconciler = Reconciler(reconcilerConfig); diff --git a/src/core/roots.js b/src/core/roots.js new file mode 100644 index 00000000..1b09e4ef --- /dev/null +++ b/src/core/roots.js @@ -0,0 +1,6 @@ +/** + * We store roots here since we can render to multiple canvases + * + * @type {Map} + */ +export const roots = new Map(); diff --git a/src/helpers/applyProps.js b/src/helpers/applyProps.js index cf165c16..81bc6a70 100644 --- a/src/helpers/applyProps.js +++ b/src/helpers/applyProps.js @@ -8,6 +8,7 @@ import { } from '../constants/EventPropNames.js'; import { diffProps } from './diffProps.js'; import { isDiffSet } from './isDiffSet.js'; +import { isReadOnlyProperty } from './isReadOnlyProperty.js'; import { log } from './log.js'; /** @typedef {import('pixi.js').FederatedPointerEvent} FederatedPointerEvent */ @@ -150,16 +151,10 @@ export function applyProps(instance, data) delete currentInstance[pixiKey]; } } - else + else if (!isReadOnlyProperty(currentInstance, key)) { - const prototype = Object.getPrototypeOf(currentInstance); - const propertyDescriptor = Object.getOwnPropertyDescriptor(prototype, key); - - if (typeof propertyDescriptor === 'undefined' || propertyDescriptor.set) - { - // @ts-expect-error The key is cast to any property of Container, including read-only properties. The check above prevents us from setting read-only properties, but TS doesn't understand it. 🤷🏻‍♂️ - currentInstance[key] = value; - } + // @ts-expect-error The key is cast to any property of Container, including read-only properties. The check above prevents us from setting read-only properties, but TS doesn't understand it. 🤷🏻‍♂️ + currentInstance[key] = value; } } diff --git a/src/helpers/isReadOnlyProperty.js b/src/helpers/isReadOnlyProperty.js new file mode 100644 index 00000000..23177236 --- /dev/null +++ b/src/helpers/isReadOnlyProperty.js @@ -0,0 +1,12 @@ +/** + * @param {Record} objectInstance + * @param {string} propertyKey + * @returns {boolean} + */ +export function isReadOnlyProperty(objectInstance, propertyKey) +{ + const prototype = Object.getPrototypeOf(objectInstance); + const propertyDescriptor = Object.getOwnPropertyDescriptor(prototype, propertyKey); + + return !(typeof propertyDescriptor === 'undefined' || propertyDescriptor.set); +} diff --git a/src/helpers/prepareInstance.js b/src/helpers/prepareInstance.js index 491ec92f..28a6c983 100644 --- a/src/helpers/prepareInstance.js +++ b/src/helpers/prepareInstance.js @@ -1,3 +1,4 @@ +/** @typedef {import('pixi.js').Container} Container */ /** @typedef {import('../typedefs/ContainerElement.js').ContainerElement} ContainerElement */ /** @typedef {import('../typedefs/Instance.js').Instance} Instance */ /** @typedef {import('../typedefs/InstanceState.js').InstanceState} InstanceState */ @@ -5,7 +6,7 @@ /** * Create the instance with the provided sate and attach the component to it. * - * @template {ContainerElement} T + * @template {Container | ContainerElement} T * @param {T} component * @param {Partial} [state] */ diff --git a/src/hooks/useIsomorphicLayoutEffect.js b/src/hooks/useIsomorphicLayoutEffect.js new file mode 100644 index 00000000..65726864 --- /dev/null +++ b/src/hooks/useIsomorphicLayoutEffect.js @@ -0,0 +1,9 @@ +import { + useEffect, + useLayoutEffect, +} from 'react'; + +export const useIsomorphicLayoutEffect + = typeof window !== 'undefined' && (window.document?.createElement || window.navigator?.product === 'ReactNative') + ? useLayoutEffect + : useEffect; diff --git a/src/index.js b/src/index.js index ade298d5..79509c07 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,10 @@ export { Application } from './components/Application.js'; +export { createRoot } from './core/createRoot.js'; export { extend } from './helpers/extend.js'; export { useApp } from './hooks/useApp.js'; export { useAsset } from './hooks/useAsset.js'; export { useExtend } from './hooks/useExtend.js'; export { useTick } from './hooks/useTick.js'; -export { render } from './render.js'; // This is stupid. `global.js` doesn't exist, but `global.ts` does. This is a // stupid, stupid, stupid thing that we have to do to get the global types to diff --git a/src/reconciler.js b/src/reconciler.js deleted file mode 100644 index 141db683..00000000 --- a/src/reconciler.js +++ /dev/null @@ -1,86 +0,0 @@ -/* eslint-disable no-empty-function */ - -import Reconciler from 'react-reconciler'; -import { afterActiveInstanceBlur } from './helpers/afterActiveInstanceBlur.js'; -import { appendChild } from './helpers/appendChild.js'; -import { beforeActiveInstanceBlur } from './helpers/beforeActiveInstanceBlur.js'; -import { clearContainer } from './helpers/clearContainer.js'; -import { commitUpdate } from './helpers/commitUpdate.js'; -import { createInstance } from './helpers/createInstance.js'; -import { createTextInstance } from './helpers/createTextInstance.js'; -import { detachDeletedInstance } from './helpers/detachDeletedInstance.js'; -import { finalizeInitialChildren } from './helpers/finalizeInitialChildren.js'; -import { getChildHostContext } from './helpers/getChildHostContext.js'; -import { getCurrentEventPriority } from './helpers/getCurrentEventPriority.js'; -import { getInstanceFromNode } from './helpers/getInstanceFromNode.js'; -import { getInstanceFromScope } from './helpers/getInstanceFromScope.js'; -import { getPublicInstance } from './helpers/getPublicInstance.js'; -import { getRootHostContext } from './helpers/getRootHostContext.js'; -import { insertBefore } from './helpers/insertBefore.js'; -import { prepareForCommit } from './helpers/prepareForCommit.js'; -import { preparePortalMount } from './helpers/preparePortalMount.js'; -import { prepareScopeUpdate } from './helpers/prepareScopeUpdate.js'; -import { prepareUpdate } from './helpers/prepareUpdate.js'; -import { removeChild } from './helpers/removeChild.js'; -import { resetAfterCommit } from './helpers/resetAfterCommit.js'; -import { shouldSetTextContent } from './helpers/shouldSetTextContent.js'; - -/** @typedef {import('./typedefs/HostConfig.js').HostConfig} HostConfig */ -/** @typedef {import('./typedefs/Instance.js').Instance} Instance */ - -/** - * @type {Reconciler.HostConfig< - * HostConfig['type'], - * HostConfig['props'], - * HostConfig['container'], - * HostConfig['instance'], - * HostConfig['textInstance'], - * HostConfig['suspenseInstance'], - * HostConfig['hydratableInstance'], - * HostConfig['publicInstance'], - * HostConfig['hostContext'], - * HostConfig['updatePayload'], - * HostConfig['childSet'], - * HostConfig['timeoutHandle'], - * HostConfig['noTimeout'] - * >} - */ -const reconcilerConfig = { - isPrimaryRenderer: false, - noTimeout: -1, - supportsHydration: false, - supportsMutation: true, - supportsPersistence: false, - - afterActiveInstanceBlur, - appendChild, - appendChildToContainer: appendChild, - appendInitialChild: appendChild, - beforeActiveInstanceBlur, - cancelTimeout: clearTimeout, - clearContainer, - commitUpdate, - createInstance, - createTextInstance, - detachDeletedInstance, - finalizeInitialChildren, - getChildHostContext, - getCurrentEventPriority, - getInstanceFromNode, - getInstanceFromScope, - getPublicInstance, - getRootHostContext, - insertBefore, - insertInContainerBefore: insertBefore, - prepareForCommit, - preparePortalMount, - prepareScopeUpdate, - prepareUpdate, - removeChild, - removeChildFromContainer: removeChild, - resetAfterCommit, - scheduleTimeout: setTimeout, - shouldSetTextContent, -}; - -export const reconciler = Reconciler(reconcilerConfig); diff --git a/src/render.js b/src/render.js deleted file mode 100644 index bf5f4801..00000000 --- a/src/render.js +++ /dev/null @@ -1,125 +0,0 @@ -import { Application } from 'pixi.js'; -import { createElement } from 'react'; -import { ConcurrentRoot } from 'react-reconciler/constants.js'; -import { ContextProvider } from './components/Context.js'; -import { prepareInstance } from './helpers/prepareInstance.js'; -import { reconciler } from './reconciler.js'; -import { store as globalStore } from './store.js'; - -// We store roots here since we can render to multiple canvases -const roots = new Map(); - -/** @typedef {import('pixi.js').ApplicationOptions} ApplicationOptions */ - -/** - * @template T - * @typedef {import('react').PropsWithChildren} PropsWithChildren - */ -/** - * @template T - * @typedef {import('react').PropsWithRef} PropsWithRef - */ -/** - * @template T - * @typedef {import('react').RefObject} RefObject - */ - -/** - * @template T - * @typedef {import('./typedefs/OmitChildren.js').OmitChildren} OmitChildren - */ - -/** @typedef {PropsWithChildren>} ApplicationPropsWithChildren */ -/** @typedef {PropsWithRef<{ ref: RefObject }>} ApplicationPropsWithRef */ -/** @typedef {Partial} RenderProps */ - -/** - * This renders an element to a canvas, creates a renderer, scene, etc. - * - * @param {import('react').ReactNode} component The component to be rendered. - * @param {HTMLElement | HTMLCanvasElement} target The target element into which the Pixi application will be rendered. Can be any element, but if a is passed the application will be rendered to it directly. - * @param {RenderProps} [props] - * @param {object} [options] - * @param {boolean} [options.enableLogging] - */ -export function render( - component, - target, - props = {}, - options = {}, -) -{ - globalStore.debug = Boolean(options.enableLogging); - - const { - children = null, - ...componentProps - } = props; - - let canvas; - - if (target instanceof HTMLCanvasElement) - { - canvas = target; - } - - // Get store and init/update Pixi.js state - const store = roots.get(target); - let root = store?.root; - const state = Object.assign((store?.state ?? {}), options); - - // Initiate root - if (!root) - { - /** @type {Partial} */ - const applicationProps = { ...componentProps }; - - if (canvas) - { - applicationProps.canvas = canvas; - } - - state.app = new Application(); - state.app.init(applicationProps); - state.rootContainer = prepareInstance(state.app.stage); - - if (!canvas) - { - target.innerHTML = ''; - target.appendChild(state.app.canvas); - canvas = state.app.canvas; - } - - if (!state.size) - { - state.size = { - height: canvas.parentElement?.clientHeight ?? 0, - width: canvas.parentElement?.clientWidth ?? 0, - }; - } - - root = reconciler.createContainer( - state.rootContainer, - ConcurrentRoot, - null, - false, - null, - '', - console.error, - null, - ); - } - - // Update root - roots.set(target, { root, state }); - - // Update fiber and expose Pixi.js state to children - reconciler.updateContainer( - createElement(ContextProvider, { value: state }, component), - root, - null, - () => undefined - ); - - return state.app; -} diff --git a/src/typedefs/InternalState.js b/src/typedefs/InternalState.js index b0319693..0e08dd6b 100644 --- a/src/typedefs/InternalState.js +++ b/src/typedefs/InternalState.js @@ -5,7 +5,9 @@ /** * @typedef {object} InternalState * @property {Application} app - * @property {boolean} debug + * @property {HTMLCanvasElement} [canvas] + * @property {boolean} [debug] + * @property {boolean} [isInitialising] * @property {Instance} rootContainer */ export const InternalState = {}; diff --git a/src/typedefs/Root.js b/src/typedefs/Root.js new file mode 100644 index 00000000..6b382dbf --- /dev/null +++ b/src/typedefs/Root.js @@ -0,0 +1,8 @@ +/** + * @typedef {object} Root + * @property {import('react-reconciler').Fiber} fiber + * @property {import('pixi.js').Container} [root] + * @property {Function} render + * @property {import('./InternalState.js').InternalState} state + */ +export const Root = {};