/* * This file belongs to Hoist, an application development toolkit * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) * * Copyright © 2026 Extremely Heavy Industries Inc. */ import {instanceManager} from '@xh/hoist/core/impl/InstanceManager'; import { CreatesSpec, UsesSpec, HoistProps, DefaultHoistProps, elementFactory, ElementFactory, TestSupportProps, PlainObject } from './'; import { useModelLinker, localModelContext, ModelLookup, ModelLookupContext, modelLookupContextProvider, ModelSpec, uses, formatSelector, HoistModel } from './model'; import {logError, throwIf, warnIf, withDefault} from '@xh/hoist/utils/js'; import {getLayoutProps, useOnMount, useOnUnmount} from '@xh/hoist/utils/react'; import classNames from 'classnames'; import {isFunction, isPlainObject, isObject} from 'lodash'; import {observer} from '../mobx'; import { ForwardedRef, forwardRef, FC, memo, ReactNode, useContext, useEffect, useRef, createElement, FunctionComponent, useDebugValue } from 'react'; /** * Type representing props passed to a HoistComponent's render function. * * This type removes from its base type several properties that are pulled out by the HoistComponent itself and * not provided to the render function. `modelConfig` and `modelRef` are resolved into the `model` property. * `ref` is passed as the second argument to the render function. */ export type RenderPropsOf

= P & { /** Pre-processed by HoistComponent internals into a mounted model. Never passed to render. */ modelConfig: never; /** Pre-processed by HoistComponent internals and attached to model. Never passed to render. */ modelRef: never; /** Pre-processed by HoistComponent internals and passed as second argument to render. */ ref: never; }; /** * Configuration for creating a Component. May be specified either as a render function, * or an object containing a render function and associated metadata. */ export type ComponentConfig

= | ((props: RenderPropsOf

, ref?: ForwardedRef) => ReactNode) | { /** Render function defining the component. */ render(props: RenderPropsOf

, ref?: ForwardedRef): ReactNode; /** * Spec defining the model to be rendered by this component. * Specify as false for components that don't require a primary model. Otherwise, set to the * return of {@link uses} or {@link creates} - these factory functions will create a spec for * either externally-provided or internally-created models. Defaults to `uses('*')`. */ model?: ModelSpec | false; /** * Base CSS class for this component. Will be combined with any className * in props, with the combined string passed into render as a prop. */ className?: string; /** Component name for debugging/inspection. */ displayName?: string; /** * True (default) to wrap component in a call to `React.memo()`. * Components that are known to be unable to make effective use of memo (e.g. container * components) may set this to `false`. Not typically set by application code. */ memo?: boolean; /** * True (default) to enable MobX-powered reactivity via the * `observer()` HOC from mobx-react. Components that are known to dereference no observable * state may set this to `false`, but this is not typically done by application code. */ observer?: boolean; /** * Static, app-wide configuration for the component, attached to the returned component * as a typed `defaults` object that applications can modify at bootstrap to customize * behavior for all instances (e.g. `Button.defaults.minimal = false` in Bootstrap.ts). * * Most commonly used to supply default values for selected props. May also be used for * other app-overridable settings that are not exposed as props. */ defaults?: D; }; let cmpIndex = 0; // index for anonymous component dispay names /** * The primary entry point for defining Hoist components - functional React components enhanced * with MobX reactivity and integrated model support. * * Accepts a configuration object (or a bare render function) and returns a React functional * component. The `model` config is the key option: use {@link creates} to have the component * create and own its backing model, {@link uses} to source a model from props or context, or * `false` for simple components with no model. Defaults to `uses('*')` if not specified. * * Components are wrapped in the MobX `observer` HOC by default, enabling automatic re-rendering * when observable state read during render changes. * * Forward refs ({@link https://reactjs.org/docs/forwarding-refs.html}) are supported by * specifying a render function with two arguments - the second is treated as a ref, and * `React.forwardRef` is applied automatically. * * Most components should be defined via one of two convenience methods rather than calling * this function directly: * - `hoistCmp.factory()` - returns an element factory (the standard pattern for app components). * - `hoistCmp.withFactory()` - returns a `[Component, factory]` pair (the standard pattern for * library components that need to export both). * * See the core package README (`core/README.md`) for full documentation on component * configuration, model specs, and context lookup behavior. * * Components can also declare a typed `defaults` object in their config to expose static, * app-wide configuration - most typically default values for selected props, but also other * settings designed to be overridden by applications at bootstrap (e.g. * `Button.defaults.minimal = false` in Bootstrap.ts). When specified, the returned component * exposes a typed `defaults` property. * * @param config - specification object, or a render function defining the component. * @returns a functional React Component for use within Hoist apps. */ export function hoistCmp( config: ComponentConfig> ): FC>; export function hoistCmp

( config: ComponentConfig ): FC

& {defaults: D}; export function hoistCmp

(config: ComponentConfig

): FC

; export function hoistCmp

(config: ComponentConfig

): FC

{ // 0) Pre-process/parse args. if (isFunction(config)) config = {render: config, displayName: config.name}; // 1) Default and validate the modelSpec. const modelSpec = withDefault(config.model, uses('*')); throwIf( modelSpec && !(modelSpec instanceof UsesSpec) && !(modelSpec instanceof CreatesSpec), "The 'model' config passed to hoistComponent() is incorrectly specified: provide a spec returned by either uses() or creates()." ); let render = config.render, cfg: Config = { className: config.className, displayName: config.displayName ? config.displayName : 'HoistCmp' + cmpIndex++, isMemo: withDefault(config.memo, true), isObserver: withDefault(config.observer, true), isForwardRef: render.length === 2, modelSpec: modelSpec ? modelSpec : null, defaults: config.defaults ?? null }; warnIf( !cfg.isMemo && cfg.isObserver, 'Cannot create an observer component without `memo`. Memo is built-in to MobX observable. Component will be memoized.' ); // 2) Wrap supplied render function with model and classname, support. Be sure to clone props. if (cfg.modelSpec) { render = wrapWithModel(render, cfg); } if (cfg.className) { render = wrapWithClassName(render, cfg); } if (cfg.modelSpec || cfg.className) { render = wrapWithClonedProps(render); } // 4) Wrap with standard react HOCs, mark, install defaults if specified, and return. let ret = render as any; ret.displayName = cfg.displayName; if (cfg.isForwardRef) ret = forwardRef(ret); if (cfg.isObserver) ret = observer(ret); if (cfg.isMemo && !cfg.isObserver) ret = memo(ret); ret.displayName = cfg.displayName; ret.isHoistComponent = true; if (cfg.defaults) ret.defaults = cfg.defaults; return ret; } /** * Alias for {@link hoistCmp}. */ export const hoistComponent = hoistCmp; /** * Return an element factory for a newly defined component. * * Most typically used by application, this provides a simple element factory. */ export function hoistCmpFactory( config: ComponentConfig> ): ElementFactory>; export function hoistCmpFactory

( config: ComponentConfig

): ElementFactory

; export function hoistCmpFactory(config) { return elementFactory(hoistCmp(config)); } hoistCmp.factory = hoistCmpFactory; /** * Returns a 2-element list containing both the newly defined Component and an elementFactory for it. * Used by Hoist for exporting Component artifacts that support both JSX and elementFactory based development. * * Not typically used by applications. */ export function hoistCmpWithFactory( config: ComponentConfig> ): [FC>, ElementFactory>]; export function hoistCmpWithFactory

( config: ComponentConfig ): [FC

& {defaults: D}, ElementFactory

]; export function hoistCmpWithFactory

( config: ComponentConfig

): [FC

, ElementFactory

]; export function hoistCmpWithFactory(config) { const cmp = hoistCmp(config); return [cmp, elementFactory(cmp)]; } hoistCmp.withFactory = hoistCmpWithFactory; //---------------------------- // Implementation //---------------------------- //---------------------------------- // internal types and core wrappers //---------------------------------- type RenderFn = (props: HoistProps & TestSupportProps, ref?: ForwardedRef) => ReactNode; interface Config { displayName: string; className: string; isObserver: boolean; isForwardRef: boolean; isMemo: boolean; modelSpec: ModelSpec; defaults: PlainObject; } interface ResolvedModel { model: HoistModel; fromContext: boolean; isLinked: boolean; } function wrapWithClassName(render: RenderFn, cfg: Config): RenderFn { return (props, ref) => { props.className = classNames(cfg.className, props.className); return render(props, ref); }; } function wrapWithClonedProps(render: RenderFn): RenderFn { return (props, ref) => render({...props}, ref); } //------------------------------------------------------------------------------------ // Lookup/create model for this component using spec, *AND* potentially publish // explicitly to children, if needed. This may require inserting a ContextProvider //------------------------------------------------------------------------------------ function wrapWithModel(render: RenderFn, cfg: Config): RenderFn { const spec = cfg.modelSpec, {publishMode} = spec, publishNone = publishMode === 'none', publishDefault = publishMode === 'default', HostCmp = createCmpHost(cfg); return (props, ref) => { // 1) Get the model and modelLookup context const modelLookup = useContext(ModelLookupContext), resolvedModel = useResolvedModel(props, modelLookup, cfg), {isLinked, model, fromContext} = resolvedModel; // 2) Validate if (!model && !spec.optional && spec instanceof UsesSpec) { logError( `Failed to find model with selector '${formatSelector(spec.selector)}'. Ensure the proper model is available via context, or specify using the 'model' prop.`, cfg.displayName ); return cmpErrDisplay({...getLayoutProps(props), item: 'No model found'}); } useDebugValue(model, m => { if (!m) return 'null'; return [ m.constructor.name, m.xhId, isLinked ? 'linked' : fromContext ? 'context' : 'props' ].join(' | '); }); // 3) insert any new lookup context that needs to be established. Avoid adding if default // model not changing; cache object to avoid triggering re-renders in context children const insertLookup = useRef(null); if ( !publishNone && model && (!modelLookup || !fromContext || (publishDefault && modelLookup.lookupModel('*') !== model)) ) { const {current} = insertLookup; if (current?.model != model || current?.parent != modelLookup) { insertLookup.current = new ModelLookup(model, modelLookup, publishMode); } } else { insertLookup.current = null; } // 4) Get the rendering of the component with its model context // 4a) Create a generic render function, that can be called immediately, or in wrapped component. const managedRender = () => { let ctx = localModelContext; try { props.model = model; delete props.modelRef; delete props.modelConfig; ctx.props = props; ctx.modelLookup = insertLookup.current ?? modelLookup; useOnMount(() => instanceManager.registerModelWithTestId(props.testId, model)); useOnUnmount(() => instanceManager.unregisterModelWithTestId(props.testId)); return render(props, ref); } finally { ctx.props = null; ctx.modelLookup = null; } }; // 4b) Get the element, either running the wrapped function directly, or in a wrapped component if needed const ret = isLinked ? managedRender() : createElement(HostCmp, {managedRender, key: model?.xhId}); return insertLookup.current ? modelLookupContextProvider({value: insertLookup.current, item: ret}) : ret; }; } //------------------------------------------------------------------------- // Support to resolve/create model at render-time. Used by wrappers above. //------------------------------------------------------------------------- function useResolvedModel(props: HoistProps, modelLookup: ModelLookup, cfg: Config): ResolvedModel { let ref = useRef(null), resolvedModel = ref.current; // 1) Lookup or create the model, as appropriate. if (!resolvedModel) { resolvedModel = cfg.modelSpec instanceof CreatesSpec ? createModel(cfg.modelSpec) : lookupModel(props, modelLookup, cfg); // 1a) Cache any linked (created) model. Only create a model once! if (resolvedModel.isLinked) ref.current = resolvedModel; } // 2) Other bookkeeping, following rules of hooks, before return. const {model, isLinked} = resolvedModel; useModelLinker(isLinked ? model : null, modelLookup, props); const {modelRef} = props; useEffect(() => { if (isFunction(modelRef)) { modelRef(model); } else if (isObject(modelRef)) { (modelRef as any).current = model; } }, [model, modelRef]); return resolvedModel; } function createModel(spec: CreatesSpec): ResolvedModel { let model = spec.createFn(); if (isFunction(model)) { model = new (model as any)(); } return {model, isLinked: true, fromContext: false}; } function lookupModel(props: HoistProps, modelLookup: ModelLookup, cfg: Config): ResolvedModel { let {model, modelConfig} = props, spec = cfg.modelSpec as UsesSpec, selector = spec.selector as any; // 1) props - config if (spec.createFromConfig) { if (isPlainObject(modelConfig)) { return {model: new selector(modelConfig), isLinked: true, fromContext: false}; } } // 2) props - instance if (model) { if (!model.isHoistModel || !model.matchesSelector(selector, true)) { logError( `Incorrect model passed. Expected: ${formatSelector(selector)} Received: ${model.constructor.name}`, cfg.displayName ); model = null; } return {model, isLinked: false, fromContext: false}; } // 3) context if (modelLookup && spec.fromContext) { const contextModel = modelLookup.lookupModel(selector); if (contextModel) return {model: contextModel, isLinked: false, fromContext: true}; } // 4) default create const create = spec.createDefault; if (create) { const model = isFunction(create) ? create() : new selector(); return {model, isLinked: true, fromContext: false}; } return {model: null, isLinked: false, fromContext: false}; } /** * @internal * * Create Internal wrapper component for HoistComponents with provided models. * * Used to ensure that the core of the component is remounted, * if the provided model changes. */ function createCmpHost(cfg: Config): FunctionComponent { let ret: FunctionComponent = props => props.managedRender(); ret = cfg.isObserver ? observer(ret) : ret; ret.displayName = cfg.displayName + 'Host'; return ret; } /** * Component to render certain errors caught within hoistComponent. * @internal */ export function setCmpErrorDisplay(ef: ElementFactory) { cmpErrDisplay = ef; } let cmpErrDisplay: ElementFactory = null;