From d3a3d22052f4f85dcf64f265e02395e6b2554047 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 14 Jun 2024 11:38:25 -0500 Subject: [PATCH] refactor: realigning with react-three-fiber --- src/helpers/appendChild.js | 18 ++- src/helpers/applyProps.js | 182 ++++++++++++++--------- src/helpers/commitUpdate.js | 32 ++++ src/helpers/compare.js | 194 +++++++++++++++++++++++++ src/helpers/createInstance.js | 44 +++--- src/helpers/createTextInstance.js | 18 ++- src/helpers/diffProps.js | 95 ++++++++++++ src/helpers/getChildHostContext.js | 13 ++ src/helpers/getCurrentEventPriority.js | 37 +++++ src/helpers/getInstanceFromScope.js | 12 ++ src/helpers/getPublicInstance.js | 13 ++ src/helpers/insertBefore.js | 30 ++++ src/helpers/invariant.js | 32 ++++ src/helpers/isDiffSet.js | 20 +++ src/helpers/log.js | 26 ++++ src/helpers/prepareInstance.js | 29 ++++ src/helpers/prepareUpdate.js | 39 +++++ src/helpers/removeChild.js | 21 +-- src/helpers/switchInstance.js | 69 +++++++++ src/hooks/useAsset.js | 13 +- src/index.js | 8 + src/reconciler.js | 67 +++------ src/render.js | 15 +- src/store.js | 5 + src/typedefs/AutoFilteredKeys.js | 13 +- src/typedefs/Change.js | 9 ++ src/typedefs/DiffSet.js | 5 + src/typedefs/EventHandlers.js | 37 +++++ src/typedefs/HostConfig.js | 16 +- src/typedefs/HostContainer.js | 7 +- src/typedefs/Instance.js | 13 +- src/typedefs/InstanceProps.js | 9 ++ src/typedefs/InstanceState.js | 15 ++ src/typedefs/MaybeInstance.js | 4 + src/typedefs/NameOverrides.js | 3 + src/typedefs/PixiElements.js | 2 +- src/typedefs/PixiElementsImpl.js | 18 ++- src/typedefs/PixiOptions.js | 13 ++ src/typedefs/TargetKeys.js | 1 - 39 files changed, 1006 insertions(+), 191 deletions(-) create mode 100644 src/helpers/commitUpdate.js create mode 100644 src/helpers/compare.js create mode 100644 src/helpers/diffProps.js create mode 100644 src/helpers/getChildHostContext.js create mode 100644 src/helpers/getCurrentEventPriority.js create mode 100644 src/helpers/getInstanceFromScope.js create mode 100644 src/helpers/getPublicInstance.js create mode 100644 src/helpers/insertBefore.js create mode 100644 src/helpers/invariant.js create mode 100644 src/helpers/isDiffSet.js create mode 100644 src/helpers/log.js create mode 100644 src/helpers/prepareInstance.js create mode 100644 src/helpers/prepareUpdate.js create mode 100644 src/helpers/switchInstance.js create mode 100644 src/store.js create mode 100644 src/typedefs/Change.js create mode 100644 src/typedefs/DiffSet.js create mode 100644 src/typedefs/EventHandlers.js create mode 100644 src/typedefs/InstanceProps.js create mode 100644 src/typedefs/InstanceState.js create mode 100644 src/typedefs/MaybeInstance.js create mode 100644 src/typedefs/NameOverrides.js create mode 100644 src/typedefs/PixiOptions.js diff --git a/src/helpers/appendChild.js b/src/helpers/appendChild.js index 80893b96..59377444 100644 --- a/src/helpers/appendChild.js +++ b/src/helpers/appendChild.js @@ -1,18 +1,22 @@ +import { log } from './log.js'; + /** @typedef {import('pixi.js').Container} Container */ -/** @typedef {import('../typedefs/HostContainer.js').HostContainer} HostContainer */ +/** @typedef {import('../typedefs/Instance.js').Instance} Instance */ /** - * Adds elements to our scene and attaches geometry and material to meshes. + * Adds elements to our application. * - * @param {HostContainer & Container} parentInstance - * @param {HostContainer & Container} child + * @param {Instance} parentInstance + * @param {Instance} childInstance */ -export function appendChild(parentInstance, child) +export function appendChild(parentInstance, childInstance) { - if (!child) + log('info', 'lifecycle::appendChild'); + + if (!childInstance) { return; } - parentInstance.addChild(child); + parentInstance.addChild(childInstance); } diff --git a/src/helpers/applyProps.js b/src/helpers/applyProps.js index 9053803b..7afe8510 100644 --- a/src/helpers/applyProps.js +++ b/src/helpers/applyProps.js @@ -1,85 +1,123 @@ -import { Graphics } from 'pixi.js'; -import { pruneKeys } from './pruneKeys.js'; +import { Container } from 'pixi.js'; +import { diffProps } from './diffProps.js'; +import { isDiffSet } from './isDiffSet.js'; +// import { pruneKeys } from './pruneKeys.js'; + +/** @typedef {import('../typedefs/DiffSet.js').DiffSet} DiffSet */ +/** @typedef {import('../typedefs/EventHandlers.js').EventHandlers} EventHandlers */ +/** @typedef {import('../typedefs/Instance.js').Instance} Instance */ +/** @typedef {import('../typedefs/InstanceProps.js').InstanceProps} InstanceProps */ +/** @typedef {import('../typedefs/MaybeInstance.js').MaybeInstance} MaybeInstance */ + +const DEFAULT = '__default'; +const DEFAULTS_CONTAINERS = new Map(); /** * Apply properties to Pixi.js instance. * - * @param {{ [key: string]: any }} instance An instance? - * @param {{ [key: string]: any }} newProps New props. - * @param {{ [key: string]: any }} [oldProps] Old props. + * @param {MaybeInstance} instance An instance? + * @param {InstanceProps | DiffSet} data New props. */ -export function applyProps(instance, newProps, oldProps = {}) +export function applyProps(instance, data) { - // Filter identical props, event handlers, and reserved keys - const identicalProps = Object - .keys(newProps) - .filter((key) => newProps[key] === oldProps[key]); + const localState = instance.__reactpixi; + const { + __reactpixi, + ...instanceProps + } = instance; - if ((instance instanceof Graphics) && !identicalProps.includes('draw')) - { - newProps.draw?.(instance); - } + /** @type {DiffSet} */ + const { changes } = /** @type {*} */ (isDiffSet(data) ? data : diffProps(instanceProps, data)); - const handlers = Object.keys(newProps).filter((key) => - { - const isFunction = typeof newProps[key] === 'function'; - - return isFunction && key.startsWith('on'); - }); - - const props = pruneKeys(newProps, [ - ...identicalProps, - ...handlers, - 'children', - 'draw', - 'key', - 'ref', - ]); - - // Mutate our Pixi.js element - if (Object.keys(props).length) + let changeIndex = 0; + + while (changeIndex < changes.length) { - Object.entries(props).forEach(([key, value]) => + const change = changes[changeIndex]; + + let key = change[0]; + let value = change[1]; + const isEvent = change[2]; + const keys = change[3]; + + /** @type {Instance} */ + let currentInstance = /** @type {*} */ (instance); + let targetProp = currentInstance[key]; + + // Resolve dashed props + if (keys.length) { - // const target = instance[key] - // const isColor = target instanceof THREE.Color - - // // Prefer to use properties' copy and set methods - // // otherwise, mutate the property directly - // if (target?.set) { - // if (target.constructor.name === value.constructor.name) { - // target.copy(value) - // } else if (Array.isArray(value)) { - // target.set(...value) - // } else if (!isColor && target?.setScalar) { - // // Allow shorthand like scale={1} - // target.setScalar(value) - // } else { - // target.set(value) - // } - - // // Auto-convert sRGB colors - // if (isColor) { - // target.convertSRGBToLinear() - // } - // } else { - // instance[key] = value - // } - instance[key] = value; - }); - } + targetProp = keys.reduce((accumulator, key) => + accumulator[key], currentInstance); - // Collect event handlers. - // We put this on an invalid prop so Pixi.js doesn't serialize handlers - // if you do ref.current.clone() or ref.current.toJSON() - if (handlers.length) - { - instance.__handlers = handlers.reduce( - (acc, key) => ({ - ...acc, - [key]: newProps[key], - }), - {}, - ); + // If the target is atomic, it forces us to switch the root + if (!(targetProp && targetProp.set)) + { + const [name, ...reverseEntries] = keys.reverse(); + + currentInstance = reverseEntries.reverse().reduce((accumulator, key) => + accumulator[key], currentInstance); + + key = name; + } + } + + // https://github.com/mrdoob/three.js/issues/21209 + // HMR/fast-refresh relies on the ability to cancel out props, but threejs + // has no means to do this. Hence we curate a small collection of value-classes + // with their respective constructor/set arguments + // For removed props, try to set default values, if possible + if (value === `${DEFAULT}remove`) + { + if (currentInstance instanceof Container) + { + // create a blank slate of the instance and copy the particular parameter. + let ctor = DEFAULTS_CONTAINERS.get(currentInstance.constructor); + + if (!ctor) + { + /** @type {Container} */ + ctor = /** @type {*} */ (currentInstance.constructor); + + // eslint-disable-next-line new-cap + ctor = new ctor(); + + DEFAULTS_CONTAINERS.set(currentInstance.constructor, ctor); + } + + value = ctor[key]; + } + else + { + // instance does not have constructor, just set it to 0 + value = 0; + } + } + + // Deal with pointer events ... + if (isEvent && localState) + { + /** @type {keyof EventHandlers} */ + const typedKey = /** @type {*} */ (key); + + if (value) + { + localState.handlers[typedKey] = /** @type {*} */ (value); + } + else + { + delete localState.handlers[typedKey]; + } + + localState.eventCount = Object.keys(localState.handlers).length; + } + else + { + currentInstance[key] = value; + } + + changeIndex += 1; } + + return instance; } diff --git a/src/helpers/commitUpdate.js b/src/helpers/commitUpdate.js new file mode 100644 index 00000000..7479dc9b --- /dev/null +++ b/src/helpers/commitUpdate.js @@ -0,0 +1,32 @@ +import { applyProps } from '../helpers/applyProps.js'; +import { log } from '../helpers/log.js'; +import { switchInstance } from './switchInstance.js'; + +/** @typedef {import('../typedefs/DiffSet.js').DiffSet} DiffSet */ +/** @typedef {import('../typedefs/HostConfig.js').HostConfig} HostConfig */ +/** @typedef {import('../typedefs/Instance.js').Instance} Instance */ +/** @typedef {import('../typedefs/InstanceProps.js').InstanceProps} InstanceProps */ + +/** + * @param {Instance} instance The instance to mutate. + * @param {[boolean, DiffSet]} updatePayload Changes to be applied. + * @param {HostConfig['type']} type The type of the component. + * @param {InstanceProps} _oldProps Unused. + * @param {InstanceProps} newProps Updated properties. + * @param {import('react-reconciler').Fiber} fiber + */ +export function commitUpdate(instance, updatePayload, type, _oldProps, newProps, fiber) +{ + log('info', 'lifecycle::commitUpdate'); + + const [reconstruct, diff] = updatePayload; + + if (reconstruct) + { + switchInstance(instance, type, newProps, fiber); + } + else + { + applyProps(instance, diff); + } +} diff --git a/src/helpers/compare.js b/src/helpers/compare.js new file mode 100644 index 00000000..0b9a1a8c --- /dev/null +++ b/src/helpers/compare.js @@ -0,0 +1,194 @@ +/** + * @param {*} input + * @returns {boolean} Whether the input is an array. + */ +export function isArray(input) +{ + return Array.isArray(input); +} + +/** + * @param {*} input + * @returns {boolean} Whether the input is a boolean. + */ +export function isBoolean(input) +{ + return typeof input === 'boolean'; +} + +/** + * @param {*} inputA The first input. + * @param {*} inputB The second input. + * @param {object} [options] Options to configure how equality is checked. + * @param {'reference' | 'shallow'} [options.arrays] Whether to compare arrays by reference a === b or by shallow equality. + * @param {'reference' | 'shallow'} [options.objects] Whether to compare objects by reference a === b or by shallow equality. + * @param {boolean} [options.strict] If true both inputA and inputB's keys must match 1:1, otherwise inputA's keys must intersect inputB's. + * @returns {boolean} Whether the inputs are equal. + */ +export function isEqual(inputA, inputB, options = {}) +{ + const { + arrays = 'reference', + objects = 'reference', + strict = true, + } = options; + + // If input types are incompatible, or one input is undefined + if (typeof inputA !== typeof inputB || !!inputA !== !!inputB) + { + return false; + } + + // Atomic, just compare a against b + if (isString(inputA) || isNumber(inputA)) + { + return inputA === inputB; + } + + const isInputAAnObject = isObject(inputA); + + if (isInputAAnObject && objects === 'reference') + { + return inputA === inputB; + } + + const isInputAAnArray = isArray(inputA); + + if (isInputAAnArray && arrays === 'reference') + { + return inputA === inputB; + } + + // If we're dealing with either an array or object, we'll shallow compare first to see if they match + if ((isInputAAnArray || isInputAAnObject) && inputA === inputB) + { + return true; + } + + // Last resort, go through keys + let key; + + // Check if inputB has all the keys of inputA + for (key in inputA) + { + if (!(key in inputB)) + { + return false; + } + } + + let input = inputA; + + if (strict) + { + input = inputB; + } + + // Check if values between keys match + if (isInputAAnObject && arrays === 'shallow' && objects === 'shallow') + { + for (key in input) + { + const equalityCheckResult = isEqual(inputA[key], inputB[key], { + strict, + objects: 'reference', + }); + + if (!equalityCheckResult) + { + return false; + } + } + } + else + { + for (key in input) + { + if (inputA[key] !== inputB[key]) + { + return false; + } + } + } + + if (isUndefined(key)) + { + if (isInputAAnArray && (inputA.length === 0) && (inputB.length === 0)) + { + return true; + } + + if (isInputAAnObject && Object.keys(inputA).length === 0 && Object.keys(inputB).length === 0) + { + return true; + } + + if (inputA !== inputB) + { + return false; + } + } + + return true; +} + +/** + * @param {*} input + * @returns {boolean} Whether the input is a function. + */ +export function isFunction(input) +{ + return typeof input === 'function'; +} + +/** + * @param {*} input + * @returns {boolean} Whether the input is a number. + */ +export function isNumber(input) +{ + return typeof input === 'number'; +} + +/** + * @param {*} input + * @returns {boolean} Whether the input is an object. + */ +export function isObject(input) +{ + if (input !== Object(input)) + { + return false; + } + + if (isArray(input)) + { + return false; + } + + if (typeof input === 'function') + { + return false; + } + + return true; +} + +/** + * @param {*} input + * @returns {boolean} Whether the input is a string. + */ +export function isString(input) +{ + return typeof input === 'string'; +} + +/** + * @param {*} input + * @returns {boolean} Whether the input is undefined. + */ +export function isUndefined(input) +{ + // eslint-disable-next-line no-void + return input === void 0; +} diff --git a/src/helpers/createInstance.js b/src/helpers/createInstance.js index a4e4ed9e..c031f4d5 100644 --- a/src/helpers/createInstance.js +++ b/src/helpers/createInstance.js @@ -1,42 +1,44 @@ import { applyProps } from './applyProps.js'; import { catalogue } from './catalogue.js'; import { convertStringToPascalCase } from './convertStringToPascalCase.js'; +import { log } from './log.js'; +import { prepareInstance } from './prepareInstance.js'; -/** @typedef {import('../typedefs/PixiElements.js').PixiElements} PixiElements */ +/** @typedef {import('../typedefs/HostConfig.js').HostConfig} HostConfig */ +/** @typedef {import('../typedefs/Instance.js').Instance} Instance */ +/** @typedef {import('../typedefs/InstanceProps.js').InstanceProps} InstanceProps */ /** - * @param {keyof PixiElements} type - * @param {Record} props - * @returns + * @param {HostConfig['type']} type + * @param {InstanceProps} props + * @param {Instance} root + * @returns {Instance} */ -export function createInstance(type, props) +export function createInstance(type, props, root) { - const { args } = props; + log('info', 'lifecycle::createInstance'); // Convert lowercase primitive to PascalCase const name = convertStringToPascalCase(type); // Get the class from an imported Pixi.js namespace - const TARGET = /** @type {new (...args: any[]) => any} */ (catalogue[name]); + const PixiComponent = /** @type {new (...args: any[]) => any} */ (catalogue[name]); - if (!TARGET) + if (!PixiComponent) { - throw new Error( - `@react/pixi: ${name} is not part of the PIXI namespace! Did you forget to extend?`, - ); + throw new Error(`${name} is not part of the PIXI namespace! Did you forget to extend?`); } - let instance; + const { + children, + ...pixiProps + } = props; - // Create instance - if (Array.isArray(args)) - { - instance = new TARGET(...args); - } - else - { - instance = new TARGET(args); - } + const instance = prepareInstance(new PixiComponent(pixiProps), { + children, + root, + type, + }); // Set initial props applyProps(instance, props); diff --git a/src/helpers/createTextInstance.js b/src/helpers/createTextInstance.js index fcb962b8..a46c31d7 100644 --- a/src/helpers/createTextInstance.js +++ b/src/helpers/createTextInstance.js @@ -1,4 +1,18 @@ -export function createTextInstance() +import { log } from './log.js'; + +/** @typedef {import('../typedefs/Instance.js').Instance} Instance */ + +/** + * text: string, rootContainer: Instance, hostContext: null, internalHandle: any + * @param {string} _text Unused. + * @param {Instance} _rootContainer Unused. + * @param {null} _hostContext Unused. + * @param {any} _internalHandle Unused. + * @throws {Error} Always throws, because we don't support this (yet). + * @returns {Instance} + */ +export function createTextInstance(_text, _rootContainer, _hostContext, _internalHandle) { - console.warn('Text is not currently supported. Please use a `` component.'); + log('info', 'lifecycle::createTextInstance'); + throw new Error('Text instances are not yet supported. Please use a `` component.'); } diff --git a/src/helpers/diffProps.js b/src/helpers/diffProps.js new file mode 100644 index 00000000..6437ac5d --- /dev/null +++ b/src/helpers/diffProps.js @@ -0,0 +1,95 @@ +import { isEqual } from './compare.js'; + +/** @typedef {import('../typedefs/DiffSet.js').DiffSet} DiffSet */ +/** @typedef {import('../typedefs/InstanceProps.js').InstanceProps} InstanceProps */ + +const DEFAULT = '__default'; + +/** + * + * @param {InstanceProps} newProps New props. + * @param {InstanceProps} oldProps Old props. + * @param {boolean} remove + * @returns {DiffSet} + */ +export function diffProps( + newProps, + oldProps = {}, + remove = false, +) +{ + const { + children: newChildren, + key: newKey, + ref: newRef, + ...newPropsRest + } = newProps; + const { + children: oldChildren, + key: oldKey, + ref: oldRef, + ...oldPropsRest + } = oldProps; + + const entries = Object.entries(newPropsRest); + + /** @type {[key: string, value: unknown, isEvent: boolean, keys: string[]][]} */ + const changes = []; + + // Catch removed props, prepend them so they can be reset or removed + if (remove) + { + const oldPropsKeys = Object.keys(oldPropsRest); + + let propIndex = 0; + + while (propIndex < oldPropsKeys.length) + { + const propKey = oldPropsKeys[propIndex]; + const isPropRemoved = !(propKey in newPropsRest); + + if (isPropRemoved) + { + entries.unshift([propKey, `${DEFAULT}remove`]); + } + + propIndex += 1; + } + } + + entries.forEach(([key, value]) => + { + // When props match bail out + if (isEqual(value, oldPropsRest[key])) + { + return; + } + + // // Collect handlers and bail out + // if (/^on(Pointer|Click|DoubleClick|ContextMenu|Wheel)/.test(key)) return changes.push([key, value, true, []]) + + // Split dashed props + /** @type {string[]} */ + let entries = []; + + if (key.includes('-')) + { + entries = key.split('-'); + } + + changes.push([key, value, false, entries]); + + // Reset pierced props + for (const prop in newPropsRest) + { + const value = newPropsRest[prop]; + + if (prop.startsWith(`${key}-`)) + { + changes.push([prop, value, false, prop.split('-')]); + } + } + }); + + return { changes }; +} diff --git a/src/helpers/getChildHostContext.js b/src/helpers/getChildHostContext.js new file mode 100644 index 00000000..96a9760a --- /dev/null +++ b/src/helpers/getChildHostContext.js @@ -0,0 +1,13 @@ +import { log } from './log.js'; + +/** + * @template T + * @param {T} parentHostContext + * @returns {T} + */ +export function getChildHostContext(parentHostContext) +{ + log('info', 'lifecycle::getChildHostContext'); + + return parentHostContext; +} diff --git a/src/helpers/getCurrentEventPriority.js b/src/helpers/getCurrentEventPriority.js new file mode 100644 index 00000000..5aa84976 --- /dev/null +++ b/src/helpers/getCurrentEventPriority.js @@ -0,0 +1,37 @@ +import { + DefaultEventPriority, + // DiscreteEventPriority, + // ContinuousEventPriority, +} from 'react-reconciler/constants.js'; +import { log } from './log.js'; + +export function getCurrentEventPriority() +{ + log('info', 'lifecycle::getCurrentEventPriority'); + + return DefaultEventPriority; + // if (typeof window === 'undefined') { + // return DefaultEventPriority; + // } + + // const name = window?.event?.type; + + // switch (name) { + // case 'click': + // case 'contextmenu': + // case 'dblclick': + // case 'pointercancel': + // case 'pointerdown': + // case 'pointerup': + // return DiscreteEventPriority; + // case 'pointermove': + // case 'pointerout': + // case 'pointerover': + // case 'pointerenter': + // case 'pointerleave': + // case 'wheel': + // return ContinuousEventPriority; + // default: + // return DefaultEventPriority; + // } +} diff --git a/src/helpers/getInstanceFromScope.js b/src/helpers/getInstanceFromScope.js new file mode 100644 index 00000000..9f5b0fb2 --- /dev/null +++ b/src/helpers/getInstanceFromScope.js @@ -0,0 +1,12 @@ +import { log } from './log.js'; + +/** + * @param {*} _scope Unused. + * @throws {Error} Always throws, because we don't support this. + * @returns {import('../typedefs/Instance.js').Instance} + */ +export function getInstanceFromScope(_scope) +{ + log('info', 'lifecycle:getInstanceFromScope'); + throw new Error('Not yet implemented.'); +} diff --git a/src/helpers/getPublicInstance.js b/src/helpers/getPublicInstance.js new file mode 100644 index 00000000..856e15b5 --- /dev/null +++ b/src/helpers/getPublicInstance.js @@ -0,0 +1,13 @@ +import { log } from './log.js'; + +/** + * @template T + * @param {T} instance + * @returns {T} + */ +export function getPublicInstance(instance) +{ + log('info', 'lifecycle::getPublicInstance'); + + return instance; +} diff --git a/src/helpers/insertBefore.js b/src/helpers/insertBefore.js new file mode 100644 index 00000000..e285a959 --- /dev/null +++ b/src/helpers/insertBefore.js @@ -0,0 +1,30 @@ +import { invariant } from './invariant.js'; +import { log } from './log.js'; + +/** @typedef {import('pixi.js').Container} Container */ +/** @typedef {import('../typedefs/Instance.js').Instance} Instance */ + +/** + * @param {Instance} parentInstance + * @param {Instance} childInstance + * @param {Instance} beforeChildInstance + */ +export function insertBefore(parentInstance, childInstance, beforeChildInstance) +{ + log('info', 'lifecycle::insertBefore'); + + invariant(childInstance === beforeChildInstance, 'Cannot insert node before itself'); + + const { component: parentComponent } = parentInstance; + const { component: childComponent } = childInstance; + const { component: beforeChildComponent } = beforeChildInstance; + + if (parentComponent.children.indexOf(childComponent) === -1) + { + parentInstance.removeChild(childInstance); + } + + const index = parentComponent.getChildIndex(beforeChildComponent); + + parentInstance.addChild(childInstance, index); +} diff --git a/src/helpers/invariant.js b/src/helpers/invariant.js new file mode 100644 index 00000000..d6fc43f0 --- /dev/null +++ b/src/helpers/invariant.js @@ -0,0 +1,32 @@ +/** + * @param {boolean} condition The condition that will trigger the violation. + * @param {string} format Formatting string. + * @param {...string} args Additional arguments to use within the string. + * @throws {Error} Throws an error is `condition` evaluates to false. + */ +export function invariant(condition, format, ...args) +{ + if (process.env.NODE_ENV === 'production') + { + return; + } + + if (!condition) + { + let error; + + if (format === undefined) + { + error = new Error('Minified exception occurred; use the non-minified dev environment for the full error message and additional helpful warnings.'); + } + else + { + let argIndex = 0; + + error = new Error(format.replace(/%s/g, () => String(args[argIndex++]))); + error.name = 'Invariant Violation'; + } + + throw error; + } +} diff --git a/src/helpers/isDiffSet.js b/src/helpers/isDiffSet.js new file mode 100644 index 00000000..f42be819 --- /dev/null +++ b/src/helpers/isDiffSet.js @@ -0,0 +1,20 @@ +/** + * @param {*} input + * @returns {boolean} Whether the input is a diff set. + */ +export function isDiffSet(input) +{ + const inputAsDiffSet = /** @type {import('../typedefs/DiffSet.js').DiffSet} */ input; + + if (!inputAsDiffSet) + { + return false; + } + + if (!inputAsDiffSet.changes) + { + return false; + } + + return true; +} diff --git a/src/helpers/log.js b/src/helpers/log.js new file mode 100644 index 00000000..e5518048 --- /dev/null +++ b/src/helpers/log.js @@ -0,0 +1,26 @@ +import { store } from '../store.js'; + +/** + * @param {'error' | 'info' | 'log' | 'warn'} logType The type of the log. + * @param {...any} args Args to be forwarded to the logging function. + */ +export function log(logType, ...args) +{ + if (!store.debug) + { + return; + } + + // eslint-disable-next-line no-console + const logMethod = console[logType]; + + if (!(logMethod instanceof Function)) + { + // eslint-disable-next-line no-console + console.warn(`Attempted to create an invalid log type: "${logType}"`); + + return; + } + + logMethod('@pixi/react', ...args); +} diff --git a/src/helpers/prepareInstance.js b/src/helpers/prepareInstance.js new file mode 100644 index 00000000..39246188 --- /dev/null +++ b/src/helpers/prepareInstance.js @@ -0,0 +1,29 @@ +/** @typedef {import('pixi.js').Container} Container */ + +/** @typedef {import('../typedefs/Instance.js').Instance} Instance */ + +/** + * Create the instance with the provided sate and attach the component to it. + * + * @template {Container} T + * @param {T} component + * @param {Partial} [state] + */ +export function prepareInstance(component, state = {}) +{ + /** @type {Instance} */ + const instance = /** @type {*} */ (component); + + instance.__reactpixi = { + children: undefined, + eventCount: 0, + handlers: {}, + parent: null, + /** @type {Instance} */ + root: /** @type {*} */ (null), + type: '', + ...state, + }; + + return instance; +} diff --git a/src/helpers/prepareUpdate.js b/src/helpers/prepareUpdate.js new file mode 100644 index 00000000..4800b697 --- /dev/null +++ b/src/helpers/prepareUpdate.js @@ -0,0 +1,39 @@ +import { diffProps } from './diffProps.js'; +import { log } from './log.js'; + +/** @typedef {import('../typedefs/DiffSet.js').DiffSet} DiffSet */ +/** @typedef {import('../typedefs/InstanceProps.js').InstanceProps} InstanceProps */ + +/** + * + * @param {import('../typedefs/Instance.js').Instance} _instance Unused. + * @param {string} _type Unused. + * @param {InstanceProps} oldProps Old props. + * @param {InstanceProps} newProps New props. + * @returns {[boolean, DiffSet] | null} + */ +export function prepareUpdate(_instance, _type, oldProps, newProps) +{ + log('info', 'lifecycle::prepareUpdate'); + + // This is a data object, let's extract critical information about it + const { + children: newChildren, + ...newPropsRest + } = newProps; + const { + children: oldChildren, + ...oldPropsRest + } = oldProps; + + // Create a diff-set, flag if there are any changes + const diff = diffProps(newPropsRest, oldPropsRest, true); + + if (diff.changes.length) + { + return [false, diff]; + } + + // Otherwise do not touch the instance + return null; +} diff --git a/src/helpers/removeChild.js b/src/helpers/removeChild.js index a66c32d0..85d07cd1 100644 --- a/src/helpers/removeChild.js +++ b/src/helpers/removeChild.js @@ -1,21 +1,16 @@ +import { log } from './log.js'; + /** @typedef {import('pixi.js').Container} Container */ -/** @typedef {import('../typedefs/HostContainer.js').HostContainer} HostContainer */ +/** @typedef {import('../typedefs/Instance.js').Instance} Instance */ /** * Removes elements from our scene and disposes of them. * - * @param {HostContainer & Container} _container Unused. - * @param {HostContainer & Container} child The child to be removed. + * @param {Instance} _parentInstance The parent instance. + * @param {Instance} childInstance The child instance to be removed. */ -export function removeChild(_container, child) +export function removeChild(_parentInstance, childInstance) { - if (!child) - { - return; - } - - if (child.destroy) - { - child.destroy(); - } + log('info', 'lifecycle::removeChild'); + childInstance.destroy(); } diff --git a/src/helpers/switchInstance.js b/src/helpers/switchInstance.js new file mode 100644 index 00000000..f87b59a8 --- /dev/null +++ b/src/helpers/switchInstance.js @@ -0,0 +1,69 @@ +import { appendChild } from './appendChild.js'; +import { createInstance } from './createInstance.js'; +import { removeChild } from './removeChild.js'; + +/** @typedef {import('../typedefs/HostConfig.js').HostConfig} HostConfig */ + +/** + * @param {HostConfig['instance']} instance + * @param {HostConfig['type']} type + * @param {HostConfig['props']} newProps + * @param {import('react-reconciler').Fiber} fiber + * @returns + */ +export function switchInstance( + instance, + type, + newProps, + fiber, +) +{ + const parent = instance.parent; + + if (!parent) + { + return; + } + + const newInstance = createInstance(type, newProps, instance.root); + + if (!instance.autoRemovedBeforeAppend) + { + removeChild(parent, instance); + } + + if (newInstance.parent) + { + newInstance.autoRemovedBeforeAppend = true; + } + + appendChild(parent, newInstance); + + // This evil hack switches the react-internal fiber node + // https://github.com/facebook/react/issues/14983 + // https://github.com/facebook/react/pull/15021 + const fibers = [fiber, fiber.alternate]; + + fibers.forEach((fiber) => + { + if (fiber !== null) + { + fiber.stateNode = newInstance; + + if (fiber.ref) + { + if (typeof fiber.ref === 'function') + { + fiber.ref(newInstance); + } + else + { + /** @type {import('react-reconciler').RefObject} */ + const ref = /** @type {*} */ (fiber.ref); + + ref.current = newInstance; + } + } + } + }); +} diff --git a/src/hooks/useAsset.js b/src/hooks/useAsset.js index 47af350d..5955b433 100644 --- a/src/hooks/useAsset.js +++ b/src/hooks/useAsset.js @@ -3,6 +3,7 @@ import { useEffect, useState, } from 'react'; +import { isEqual } from '../helpers/compare.js'; /** * @typedef {import('pixi.js').ProgressCallback} ProgressCallback @@ -21,12 +22,20 @@ export function useAsset(options, onProgress) { const [isLoaded, setIsLoaded] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [memoizedOptions, setMemoizedOptions] = useState(options); const [texture, setTexture] = useState(null); useEffect(() => { - setIsLoaded(false); - }, [options]); + if (isEqual(options, memoizedOptions)) + { + setMemoizedOptions(options); + setIsLoaded(false); + } + }, [ + memoizedOptions, + options, + ]); useEffect(() => { diff --git a/src/index.js b/src/index.js index 31378659..33e03423 100644 --- a/src/index.js +++ b/src/index.js @@ -2,3 +2,11 @@ export { extend } from './helpers/extend.js'; export { useAsset } from './hooks/useAsset.js'; export { useExtend } from './hooks/useExtend.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 +// import. +// +// If you or someone you know has been hurt by Typescript, you may be entitled +// to benefits. Please call 'tel:555-555-5555' in your browser devtools. +export * from './global.js'; diff --git a/src/reconciler.js b/src/reconciler.js index 415ae846..55fee749 100644 --- a/src/reconciler.js +++ b/src/reconciler.js @@ -1,14 +1,20 @@ /* eslint-disable no-empty-function */ import Reconciler from 'react-reconciler'; -import { DefaultEventPriority } from 'react-reconciler/constants.js'; import { appendChild } from './helpers/appendChild.js'; -import { applyProps } from './helpers/applyProps.js'; +import { commitUpdate } from './helpers/commitUpdate.js'; import { createInstance } from './helpers/createInstance.js'; +import { createTextInstance } from './helpers/createTextInstance.js'; +import { getChildHostContext } from './helpers/getChildHostContext.js'; +import { getCurrentEventPriority } from './helpers/getCurrentEventPriority.js'; +import { getInstanceFromScope } from './helpers/getInstanceFromScope.js'; +import { getPublicInstance } from './helpers/getPublicInstance.js'; +import { insertBefore } from './helpers/insertBefore.js'; +import { prepareUpdate } from './helpers/prepareUpdate.js'; import { removeChild } from './helpers/removeChild.js'; /** @typedef {import('./typedefs/HostConfig.js').HostConfig} HostConfig */ -/** @typedef {import('./typedefs/Node.js').Node} Node */ +/** @typedef {import('./typedefs/Instance.js').Instance} Instance */ /** * @type {Reconciler.HostConfig< @@ -38,19 +44,26 @@ const reconcilerConfig = { appendChildToContainer: appendChild, appendInitialChild: appendChild, cancelTimeout: clearTimeout, + commitUpdate, createInstance, + createTextInstance, + getChildHostContext, + getCurrentEventPriority, + getInstanceFromScope, + getPublicInstance, + insertBefore, + insertInContainerBefore: insertBefore, + prepareUpdate, removeChild, removeChildFromContainer: removeChild, scheduleTimeout: setTimeout, afterActiveInstanceBlur() {}, beforeActiveInstanceBlur() {}, - createTextInstance() {}, detachDeletedInstance() {}, - getInstanceFromScope() {}, - insertBefore() {}, preparePortalMount() {}, prepareScopeUpdate() {}, + resetAfterCommit() {}, clearContainer() { @@ -60,62 +73,22 @@ const reconcilerConfig = { { return false; }, - getChildHostContext() - { - return null; - }, - getCurrentEventPriority() - { - return DefaultEventPriority; - }, getInstanceFromNode() { return null; }, - /** - * @template T - * @param {T} instance - * @returns {T} - */ - getPublicInstance(instance) - { - return instance; - }, getRootHostContext() { return null; }, prepareForCommit() { - return {}; - }, - prepareUpdate() - { - return {}; - }, - resetAfterCommit() - { - return {}; + return null; }, shouldSetTextContent() { return false; }, - - /** - * @param {Node} instance - * @param {*} _updatePayload Unused. - * @param {*} _type Unused. - * @param {{}} oldProps - * @param {{}} newProps - */ - commitUpdate(instance, _updatePayload, _type, oldProps, newProps) - { - // This is where we mutate Pixi.js objects in the render phase - instance.busy = true; - applyProps(instance, newProps, oldProps); - instance.busy = false; - }, }; export const reconciler = Reconciler(reconcilerConfig); diff --git a/src/render.js b/src/render.js index c567693f..da6a7355 100644 --- a/src/render.js +++ b/src/render.js @@ -4,7 +4,9 @@ import { createElement, } from 'react'; import { ConcurrentRoot } from 'react-reconciler/constants.js'; +import { prepareInstance } from './helpers/prepareInstance.js'; import { reconciler } from './reconciler.js'; +import { store as globalStore } from './store.js'; /** * Internal Pixi.js state. @@ -24,13 +26,18 @@ const roots = new Map(); * @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 @@ -74,6 +81,7 @@ export function render( state.app = new Application(); state.app.init(applicationProps); + state.rootContainer = prepareInstance(state.app.stage); if (!canvas) { @@ -91,7 +99,7 @@ export function render( } root = reconciler.createContainer( - state.app.stage, + state.rootContainer, ConcurrentRoot, null, false, @@ -112,11 +120,6 @@ export function render( // } } - // // Handle resize - // state.gl.setSize(size.width, size.height) - // state.camera.aspect = size.width / size.height - // state.camera.updateProjectionMatrix() - // Update root roots.set(target, { root, state }); diff --git a/src/store.js b/src/store.js new file mode 100644 index 00000000..7c63055e --- /dev/null +++ b/src/store.js @@ -0,0 +1,5 @@ +const store = { + debug: false, +}; + +export { store }; diff --git a/src/typedefs/AutoFilteredKeys.js b/src/typedefs/AutoFilteredKeys.js index 6c2d4eae..5af80d5c 100644 --- a/src/typedefs/AutoFilteredKeys.js +++ b/src/typedefs/AutoFilteredKeys.js @@ -2,12 +2,11 @@ /** * @typedef {{ - * [K in keyof PixiType]: K extends import('./TargetKeys.js').TargetKeys - * ? PixiType[K] extends new (...args: any) => any - * ? K - * : never - * : never; - * }[keyof PixiType] - * } AutoFilteredKeys + * [K in keyof PixiType]: K extends import('./TargetKeys.js').TargetKeys + * ? PixiType[K] extends new (...args: any) => any + * ? K + * : never + * : never; + * }[keyof PixiType]} AutoFilteredKeys */ export const AutoFilteredKeys = {}; diff --git a/src/typedefs/Change.js b/src/typedefs/Change.js new file mode 100644 index 00000000..36231113 --- /dev/null +++ b/src/typedefs/Change.js @@ -0,0 +1,9 @@ +/** + * @typedef {[ + * key: string, + * value: unknown, + * isEvent: boolean, + * keys: string[], + * ]} Change + */ +export const Change = {}; diff --git a/src/typedefs/DiffSet.js b/src/typedefs/DiffSet.js new file mode 100644 index 00000000..ac60bc3e --- /dev/null +++ b/src/typedefs/DiffSet.js @@ -0,0 +1,5 @@ +/** + * @typedef {object} DiffSet + * @property {import('./Change.js').Change[]} changes + */ +export const DiffSet = {}; diff --git a/src/typedefs/EventHandlers.js b/src/typedefs/EventHandlers.js new file mode 100644 index 00000000..321eef49 --- /dev/null +++ b/src/typedefs/EventHandlers.js @@ -0,0 +1,37 @@ +/** + * @typedef {object} EventHandlers + * @property {import('react').MouseEventHandler} onclick + * @property {import('react').MouseEventHandler} onglobalmousemove + * @property {import('react').MouseEventHandler} onglobalpointermove + * @property {import('react').MouseEventHandler} onglobaltouchmove + * @property {import('react').MouseEventHandler} onmousedown + * @property {import('react').MouseEventHandler} onmouseenter + * @property {import('react').MouseEventHandler} onmouseleave + * @property {import('react').MouseEventHandler} onmousemove + * @property {import('react').MouseEventHandler} onmouseout + * @property {import('react').MouseEventHandler} onmouseover + * @property {import('react').MouseEventHandler} onmouseup + * @property {import('react').MouseEventHandler} onmouseupoutside + * @property {import('react').MouseEventHandler} onpointercancel + * @property {import('react').MouseEventHandler} onpointerdown + * @property {import('react').MouseEventHandler} onpointerenter + * @property {import('react').MouseEventHandler} onpointerleave + * @property {import('react').MouseEventHandler} onpointermove + * @property {import('react').MouseEventHandler} onpointerout + * @property {import('react').MouseEventHandler} onpointerover + * @property {import('react').MouseEventHandler} onpointertap + * @property {import('react').MouseEventHandler} onpointerup + * @property {import('react').MouseEventHandler} onpointerupoutside + * @property {import('react').MouseEventHandler} onrightclick + * @property {import('react').MouseEventHandler} onrightdown + * @property {import('react').MouseEventHandler} onrightup + * @property {import('react').MouseEventHandler} onrightupoutside + * @property {import('react').MouseEventHandler} ontap + * @property {import('react').MouseEventHandler} ontouchcancel + * @property {import('react').MouseEventHandler} ontouchend + * @property {import('react').MouseEventHandler} ontouchendoutside + * @property {import('react').MouseEventHandler} ontouchmove + * @property {import('react').MouseEventHandler} ontouchstart + * @property {import('react').MouseEventHandler} onwheel + */ +export const EventHandlers = {}; diff --git a/src/typedefs/HostConfig.js b/src/typedefs/HostConfig.js index 8652f142..56bd40e0 100644 --- a/src/typedefs/HostConfig.js +++ b/src/typedefs/HostConfig.js @@ -1,16 +1,16 @@ -/** @typedef {import('./HostContainer.js').HostContainer} HostContainer */ -/** @typedef {import('./Node.js').Node} Node */ +/** @typedef {import('./Instance.js').Instance} Instance */ +/** @typedef {import('../typedefs/PixiElements.js').PixiElements} PixiElements */ /** * @typedef {object} HostConfig - * @property {string} type + * @property {keyof PixiElements} type * @property {Record} props - * @property {HostContainer} container - * @property {Node} instance - * @property {Node} textInstance - * @property {Node} suspenseInstance + * @property {Instance} container + * @property {Instance} instance + * @property {Instance} textInstance + * @property {Instance} suspenseInstance * @property {never} hydratableInstance - * @property {null} publicInstance + * @property {Instance} publicInstance * @property {null} hostContext * @property {object} updatePayload * @property {never} childSet diff --git a/src/typedefs/HostContainer.js b/src/typedefs/HostContainer.js index 18d44a1d..666bef2e 100644 --- a/src/typedefs/HostContainer.js +++ b/src/typedefs/HostContainer.js @@ -1,5 +1,10 @@ /** - * @typedef {object} HostContainer + * @typedef {object} BaseHostContainer + * @property {import('./Node.js').Node | null} head + */ + +/** + * @typedef {import('pixi.js').Application & BaseHostContainer} HostContainer * @property {import('./Node.js').Node | null} head */ export const HostContainer = {}; diff --git a/src/typedefs/Instance.js b/src/typedefs/Instance.js index f6ec7621..3d31fb50 100644 --- a/src/typedefs/Instance.js +++ b/src/typedefs/Instance.js @@ -1,2 +1,13 @@ -/** @typedef {{ [key: string]: any }} Instance */ +/** @typedef {import('pixi.js').Container} Container */ + +/** @typedef {import('./EventHandlers.js').EventHandlers} EventHandlers */ +/** @typedef {import('./InstanceState.js').InstanceState} InstanceState */ + +/** + * @typedef {object} BaseInstance + * @property {InstanceState} [__pixireact] + */ + +/** @typedef {{ [key: string]: any } & Container & BaseInstance} Instance */ + export const Instance = {}; diff --git a/src/typedefs/InstanceProps.js b/src/typedefs/InstanceProps.js new file mode 100644 index 00000000..29df669f --- /dev/null +++ b/src/typedefs/InstanceProps.js @@ -0,0 +1,9 @@ +/** @typedef {import('./Instance.js').Instance} Instance */ +/** + * @typedef {object} BaseInstanceProps + * @property {any} [children] + * @property {(...args: any[]) => any} [draw] + */ + +/** @typedef {{ [key: string]: unknown } & BaseInstanceProps} InstanceProps */ +export const InstanceProps = {}; diff --git a/src/typedefs/InstanceState.js b/src/typedefs/InstanceState.js new file mode 100644 index 00000000..31313498 --- /dev/null +++ b/src/typedefs/InstanceState.js @@ -0,0 +1,15 @@ +/** @typedef {import('pixi.js').Container} Container */ + +/** @typedef {import('./EventHandlers.js').EventHandlers} EventHandlers */ +/** @typedef {import('./Instance.js').Instance} Instance */ + +/** + * @typedef {object} InstanceState + * @property {boolean} [autoRemovedBeforeAppend] + * @property {number} eventCount + * @property {Partial} handlers + * @property {null | Instance} parent + * @property {Instance} root + * @property {string} type + */ +export const InstanceState = {}; diff --git a/src/typedefs/MaybeInstance.js b/src/typedefs/MaybeInstance.js new file mode 100644 index 00000000..ae9992b1 --- /dev/null +++ b/src/typedefs/MaybeInstance.js @@ -0,0 +1,4 @@ +/** @typedef {import('./Instance.js').Instance} Instance */ + +/** @typedef {Omit & object} MaybeInstance */ +export const MaybeInstance = {}; diff --git a/src/typedefs/NameOverrides.js b/src/typedefs/NameOverrides.js new file mode 100644 index 00000000..69677421 --- /dev/null +++ b/src/typedefs/NameOverrides.js @@ -0,0 +1,3 @@ +export const NameOverrides = { + HTMLText: 'htmlText', +}; diff --git a/src/typedefs/PixiElements.js b/src/typedefs/PixiElements.js index 327181b4..1adc415b 100644 --- a/src/typedefs/PixiElements.js +++ b/src/typedefs/PixiElements.js @@ -4,7 +4,7 @@ /** * @typedef {{ * [K in import('./AutoFilteredKeys.js').AutoFilteredKeys]: [ - * Lowercase, + * Uncapitalize, * React.PropsWithChildren< * import('./ConstructorParams.js').ConstructorParams * & { init?: readonly any[] } diff --git a/src/typedefs/PixiElementsImpl.js b/src/typedefs/PixiElementsImpl.js index e49d04c1..9fb193cc 100644 --- a/src/typedefs/PixiElementsImpl.js +++ b/src/typedefs/PixiElementsImpl.js @@ -1,9 +1,23 @@ -/** @typedef {import('./PixiElements.js').PixiElements} PixiElements */ +import { NameOverrides } from './NameOverrides.js'; + /** @typedef {typeof import('pixi.js')} PixiType */ +/** @typedef {import('./AutoFilteredKeys.js').AutoFilteredKeys} AutoFilteredKeys */ +/** @typedef {import('./PixiElements.js').PixiElements} PixiElements */ +/** @typedef {import('./PixiOptions.js').PixiOptions} PixiOptions */ + +/** + * @template {T extends PixiOptions ? T : never} T + * @typedef {T} PixiOptionsType + */ + /** * @typedef {{ - * [K in keyof PixiElements as PixiElements[K][0]]: PixiElements[K][1]; + * [K in AutoFilteredKeys as K extends keyof typeof NameOverrides ? typeof NameOverrides[K] : Uncapitalize]: + * import('react').PropsWithChildren< + * PixiOptionsType> + * & { init?: import('./ConstructorParams.js').ConstructorParams } + * > & import('react').PropsWithRef<{ ref?: import('react').MutableRefObject> }> * }} PixiElementsImpl */ export const PixiElementsImpl = {}; diff --git a/src/typedefs/PixiOptions.js b/src/typedefs/PixiOptions.js new file mode 100644 index 00000000..2abf8be8 --- /dev/null +++ b/src/typedefs/PixiOptions.js @@ -0,0 +1,13 @@ +/** + * @typedef { + * | import('pixi.js').ContainerOptions + * | import('pixi.js').NineSliceSpriteOptions + * | import('pixi.js').TilingSpriteOptions + * | import('pixi.js').SpriteOptions + * | import('pixi.js').MeshOptions + * | import('pixi.js').GraphicsOptions + * | import('pixi.js').TextOptions + * | import('pixi.js').HTMLTextOptions + * } PixiOptions + */ +export const PixiOptions = {}; diff --git a/src/typedefs/TargetKeys.js b/src/typedefs/TargetKeys.js index 27e0826c..73234c6a 100644 --- a/src/typedefs/TargetKeys.js +++ b/src/typedefs/TargetKeys.js @@ -7,7 +7,6 @@ * | 'Mesh' * | 'Sprite' * | 'Graphics' - * | 'Text' * | 'HTMLText' * } TargetKeys */