Skip to content

Commit

Permalink
refactor: handle async application initialisation
Browse files Browse the repository at this point in the history
  • Loading branch information
trezy committed Jun 27, 2024
1 parent 4657710 commit 3060c8f
Show file tree
Hide file tree
Showing 13 changed files with 310 additions and 242 deletions.
78 changes: 59 additions & 19 deletions src/components/Application.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -27,6 +28,8 @@ import { render } from '../render.js';
* @typedef {import('../typedefs/OmitChildren.js').OmitChildren<T>} OmitChildren
*/

/** @typedef {import('../typedefs/Root.js').Root} Root */

/**
* @template T
* @typedef {T extends undefined ? never : Omit<T, 'resizeTo'>} OmitResizeTo
Expand All @@ -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<HTMLElement> }} ResizeToProp */
Expand All @@ -55,55 +59,91 @@ export const ApplicationFunction = (props, forwardedRef) =>
attachToDevTools,
children,
className,
onInit,
resizeTo,
...applicationProps
} = props;

/** @type {MutableRefObject<PixiApplication | null>} */
const applicationRef = useRef(null);

/** @type {RefObject<HTMLCanvasElement>} */
/** @type {MutableRefObject<HTMLCanvasElement | null>} */
const canvasRef = useRef(null);

useImperativeHandle(forwardedRef, () => /** @type {PixiApplication} */ /** @type {*} */ (applicationRef.current));
/** @type {MutableRefObject<Root | null>} */
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;

Expand Down
120 changes: 120 additions & 0 deletions src/core/createRoot.js
Original file line number Diff line number Diff line change
@@ -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 <canvas> is passed the application will be rendered to it directly.
* @param {Partial<InternalState>} [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<Application>}
*/
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;
}
86 changes: 86 additions & 0 deletions src/core/reconciler.js
Original file line number Diff line number Diff line change
@@ -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);
6 changes: 6 additions & 0 deletions src/core/roots.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* We store roots here since we can render to multiple canvases
*
* @type {Map<HTMLElement, import('../typedefs/Root.js').Root>}
*/
export const roots = new Map();
13 changes: 4 additions & 9 deletions src/helpers/applyProps.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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;
}
}

Expand Down
12 changes: 12 additions & 0 deletions src/helpers/isReadOnlyProperty.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* @param {Record<any, any>} 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);
}
Loading

0 comments on commit 3060c8f

Please sign in to comment.