mirror of
https://github.com/odoo/owl.git
synced 2025-10-06 19:59:41 +07:00
9b1ec5dc0e
This commit moves OwlError to its own file in the new common folder such that it is no longer associated with the runtime.
385 lines
12 KiB
TypeScript
385 lines
12 KiB
TypeScript
import type { App, Env } from "./app";
|
|
import { BDom, VNode } from "./blockdom";
|
|
import { Component, ComponentConstructor, Props } from "./component";
|
|
import { fibersInError } from "./error_handling";
|
|
import { OwlError } from "../common/owl_error";
|
|
import { Fiber, makeChildFiber, makeRootFiber, MountFiber, MountOptions } from "./fibers";
|
|
import { clearReactivesForCallback, getSubscriptions, reactive, targets } from "./reactivity";
|
|
import { STATUS } from "./status";
|
|
import { batched, Callback } from "./utils";
|
|
|
|
let currentNode: ComponentNode | null = null;
|
|
|
|
export function getCurrent(): ComponentNode {
|
|
if (!currentNode) {
|
|
throw new OwlError("No active component (a hook function should only be called in 'setup')");
|
|
}
|
|
return currentNode;
|
|
}
|
|
|
|
export function useComponent(): Component {
|
|
return currentNode!.component;
|
|
}
|
|
|
|
/**
|
|
* Apply default props (only top level).
|
|
*/
|
|
function applyDefaultProps<P extends object>(props: P, defaultProps: Partial<P>) {
|
|
for (let propName in defaultProps) {
|
|
if (props[propName] === undefined) {
|
|
(props as any)[propName] = defaultProps[propName];
|
|
}
|
|
}
|
|
}
|
|
// -----------------------------------------------------------------------------
|
|
// Integration with reactivity system (useState)
|
|
// -----------------------------------------------------------------------------
|
|
|
|
const batchedRenderFunctions = new WeakMap<ComponentNode, Callback>();
|
|
/**
|
|
* Creates a reactive object that will be observed by the current component.
|
|
* Reading data from the returned object (eg during rendering) will cause the
|
|
* component to subscribe to that data and be rerendered when it changes.
|
|
*
|
|
* @param state the state to observe
|
|
* @returns a reactive object that will cause the component to re-render on
|
|
* relevant changes
|
|
* @see reactive
|
|
*/
|
|
export function useState<T extends object>(state: T): T {
|
|
const node = getCurrent();
|
|
let render = batchedRenderFunctions.get(node)!;
|
|
if (!render) {
|
|
render = batched(node.render.bind(node, false));
|
|
batchedRenderFunctions.set(node, render);
|
|
// manual implementation of onWillDestroy to break cyclic dependency
|
|
node.willDestroy.push(clearReactivesForCallback.bind(null, render));
|
|
}
|
|
return reactive(state, render);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Component VNode class
|
|
// -----------------------------------------------------------------------------
|
|
|
|
type LifecycleHook = Function;
|
|
|
|
export class ComponentNode<P extends Props = any, E = any> implements VNode<ComponentNode<P, E>> {
|
|
el?: HTMLElement | Text | undefined;
|
|
app: App;
|
|
fiber: Fiber | null = null;
|
|
component: Component<P, E>;
|
|
bdom: BDom | null = null;
|
|
status: STATUS = STATUS.NEW;
|
|
forceNextRender: boolean = false;
|
|
parentKey: string | null;
|
|
props: P;
|
|
nextProps: P | null = null;
|
|
|
|
renderFn: Function;
|
|
parent: ComponentNode | null;
|
|
childEnv: Env;
|
|
children: { [key: string]: ComponentNode } = Object.create(null);
|
|
refs: any = {};
|
|
|
|
willStart: LifecycleHook[] = [];
|
|
willUpdateProps: LifecycleHook[] = [];
|
|
willUnmount: LifecycleHook[] = [];
|
|
mounted: LifecycleHook[] = [];
|
|
willPatch: LifecycleHook[] = [];
|
|
patched: LifecycleHook[] = [];
|
|
willDestroy: LifecycleHook[] = [];
|
|
|
|
constructor(
|
|
C: ComponentConstructor<P, E>,
|
|
props: P,
|
|
app: App,
|
|
parent: ComponentNode | null,
|
|
parentKey: string | null
|
|
) {
|
|
currentNode = this;
|
|
this.app = app;
|
|
this.parent = parent;
|
|
this.props = props;
|
|
this.parentKey = parentKey;
|
|
const defaultProps = C.defaultProps;
|
|
props = Object.assign({}, props);
|
|
if (defaultProps) {
|
|
applyDefaultProps(props, defaultProps);
|
|
}
|
|
const env = (parent && parent.childEnv) || app.env;
|
|
this.childEnv = env;
|
|
for (const key in props) {
|
|
const prop = props[key];
|
|
if (prop && typeof prop === "object" && targets.has(prop)) {
|
|
props[key] = useState(prop);
|
|
}
|
|
}
|
|
this.component = new C(props, env, this);
|
|
const ctx = Object.assign(Object.create(this.component), { this: this.component });
|
|
this.renderFn = app.getTemplate(C.template).bind(this.component, ctx, this);
|
|
this.component.setup();
|
|
currentNode = null;
|
|
}
|
|
|
|
mountComponent(target: any, options?: MountOptions) {
|
|
const fiber = new MountFiber(this, target, options);
|
|
this.app.scheduler.addFiber(fiber);
|
|
this.initiateRender(fiber);
|
|
}
|
|
|
|
async initiateRender(fiber: Fiber | MountFiber) {
|
|
this.fiber = fiber;
|
|
if (this.mounted.length) {
|
|
fiber.root!.mounted.push(fiber);
|
|
}
|
|
const component = this.component;
|
|
try {
|
|
await Promise.all(this.willStart.map((f) => f.call(component)));
|
|
} catch (e) {
|
|
this.app.handleError({ node: this, error: e });
|
|
return;
|
|
}
|
|
if (this.status === STATUS.NEW && this.fiber === fiber) {
|
|
fiber.render();
|
|
}
|
|
}
|
|
|
|
async render(deep: boolean) {
|
|
if (this.status >= STATUS.CANCELLED) {
|
|
return;
|
|
}
|
|
let current = this.fiber;
|
|
if (current && (current.root!.locked || (current as any).bdom === true)) {
|
|
await Promise.resolve();
|
|
// situation may have changed after the microtask tick
|
|
current = this.fiber;
|
|
}
|
|
if (current) {
|
|
if (!current.bdom && !fibersInError.has(current)) {
|
|
if (deep) {
|
|
// we want the render from this point on to be with deep=true
|
|
current.deep = deep;
|
|
}
|
|
return;
|
|
}
|
|
// if current rendering was with deep=true, we want this one to be the same
|
|
deep = deep || current.deep;
|
|
} else if (!this.bdom) {
|
|
return;
|
|
}
|
|
|
|
const fiber = makeRootFiber(this);
|
|
fiber.deep = deep;
|
|
this.fiber = fiber;
|
|
|
|
this.app.scheduler.addFiber(fiber);
|
|
await Promise.resolve();
|
|
if (this.status >= STATUS.CANCELLED) {
|
|
return;
|
|
}
|
|
// We only want to actually render the component if the following two
|
|
// conditions are true:
|
|
// * this.fiber: it could be null, in which case the render has been cancelled
|
|
// * (current || !fiber.parent): if current is not null, this means that the
|
|
// render function was called when a render was already occurring. In this
|
|
// case, the pending rendering was cancelled, and the fiber needs to be
|
|
// rendered to complete the work. If current is null, we check that the
|
|
// fiber has no parent. If that is the case, the fiber was downgraded from
|
|
// a root fiber to a child fiber in the previous microtick, because it was
|
|
// embedded in a rendering coming from above, so the fiber will be rendered
|
|
// in the next microtick anyway, so we should not render it again.
|
|
if (this.fiber === fiber && (current || !fiber.parent)) {
|
|
fiber.render();
|
|
}
|
|
}
|
|
|
|
cancel() {
|
|
this._cancel();
|
|
delete this.parent!.children[this.parentKey!];
|
|
this.app.scheduler.scheduleDestroy(this);
|
|
}
|
|
|
|
_cancel() {
|
|
this.status = STATUS.CANCELLED;
|
|
const children = this.children;
|
|
for (let childKey in children) {
|
|
children[childKey]._cancel();
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
let shouldRemove = this.status === STATUS.MOUNTED;
|
|
this._destroy();
|
|
if (shouldRemove) {
|
|
this.bdom!.remove();
|
|
}
|
|
}
|
|
|
|
_destroy() {
|
|
const component = this.component;
|
|
if (this.status === STATUS.MOUNTED) {
|
|
for (let cb of this.willUnmount) {
|
|
cb.call(component);
|
|
}
|
|
}
|
|
for (let child of Object.values(this.children)) {
|
|
child._destroy();
|
|
}
|
|
if (this.willDestroy.length) {
|
|
try {
|
|
for (let cb of this.willDestroy) {
|
|
cb.call(component);
|
|
}
|
|
} catch (e) {
|
|
this.app.handleError({ error: e, node: this });
|
|
}
|
|
}
|
|
this.status = STATUS.DESTROYED;
|
|
}
|
|
|
|
async updateAndRender(props: P, parentFiber: Fiber) {
|
|
this.nextProps = props;
|
|
props = Object.assign({}, props);
|
|
// update
|
|
const fiber = makeChildFiber(this, parentFiber);
|
|
this.fiber = fiber;
|
|
const component = this.component;
|
|
const defaultProps = (component.constructor as any).defaultProps;
|
|
if (defaultProps) {
|
|
applyDefaultProps(props, defaultProps);
|
|
}
|
|
|
|
currentNode = this;
|
|
for (const key in props) {
|
|
const prop = props[key];
|
|
if (prop && typeof prop === "object" && targets.has(prop)) {
|
|
props[key] = useState(prop);
|
|
}
|
|
}
|
|
currentNode = null;
|
|
const prom = Promise.all(this.willUpdateProps.map((f) => f.call(component, props)));
|
|
await prom;
|
|
if (fiber !== this.fiber) {
|
|
return;
|
|
}
|
|
component.props = props;
|
|
fiber.render();
|
|
const parentRoot = parentFiber.root!;
|
|
if (this.willPatch.length) {
|
|
parentRoot.willPatch.push(fiber);
|
|
}
|
|
if (this.patched.length) {
|
|
parentRoot.patched.push(fiber);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Finds a child that has dom that is not yet updated, and update it. This
|
|
* method is meant to be used only in the context of repatching the dom after
|
|
* a mounted hook failed and was handled.
|
|
*/
|
|
updateDom() {
|
|
if (!this.fiber) {
|
|
return;
|
|
}
|
|
if (this.bdom === this.fiber!.bdom) {
|
|
// If the error was handled by some child component, we need to find it to
|
|
// apply its change
|
|
for (let k in this.children) {
|
|
const child = this.children[k];
|
|
child.updateDom();
|
|
}
|
|
} else {
|
|
// if we get here, this is the component that handled the error and rerendered
|
|
// itself, so we can simply patch the dom
|
|
this.bdom!.patch(this.fiber!.bdom, false);
|
|
this.fiber!.appliedToDom = true;
|
|
this.fiber = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets a ref to a given HTMLElement.
|
|
*
|
|
* @param name the name of the ref to set
|
|
* @param el the HTMLElement to set the ref to. The ref is not set if the el
|
|
* is null, but useRef will not return elements that are not in the DOM
|
|
*/
|
|
setRef(name: string, el: HTMLElement | null) {
|
|
if (el) {
|
|
this.refs[name] = el;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Block DOM methods
|
|
// ---------------------------------------------------------------------------
|
|
|
|
firstNode(): Node | undefined {
|
|
const bdom = this.bdom;
|
|
return bdom ? bdom.firstNode() : undefined;
|
|
}
|
|
|
|
mount(parent: HTMLElement, anchor: ChildNode) {
|
|
const bdom = this.fiber!.bdom!;
|
|
this.bdom = bdom;
|
|
bdom.mount(parent, anchor);
|
|
this.status = STATUS.MOUNTED;
|
|
this.fiber!.appliedToDom = true;
|
|
this.children = this.fiber!.childrenMap;
|
|
this.fiber = null;
|
|
}
|
|
|
|
moveBeforeDOMNode(node: Node | null, parent?: HTMLElement): void {
|
|
this.bdom!.moveBeforeDOMNode(node, parent);
|
|
}
|
|
|
|
moveBeforeVNode(other: ComponentNode<P, E> | null, afterNode: Node | null) {
|
|
this.bdom!.moveBeforeVNode(other ? other.bdom : null, afterNode);
|
|
}
|
|
|
|
patch() {
|
|
if (this.fiber && this.fiber.parent) {
|
|
// we only patch here renderings coming from above. renderings initiated
|
|
// by the component will be patched independently in the appropriate
|
|
// fiber.complete
|
|
this._patch();
|
|
this.props = this.nextProps!;
|
|
}
|
|
}
|
|
_patch() {
|
|
let hasChildren = false;
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
for (let _k in this.children) {
|
|
hasChildren = true;
|
|
break;
|
|
}
|
|
const fiber = this.fiber!;
|
|
this.children = fiber.childrenMap;
|
|
this.bdom!.patch(fiber.bdom!, hasChildren);
|
|
fiber.appliedToDom = true;
|
|
this.fiber = null;
|
|
}
|
|
|
|
beforeRemove() {
|
|
this._destroy();
|
|
}
|
|
|
|
remove() {
|
|
this.bdom!.remove();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Some debug helpers
|
|
// ---------------------------------------------------------------------------
|
|
get name(): string {
|
|
return this.component.constructor.name;
|
|
}
|
|
|
|
get subscriptions(): ReturnType<typeof getSubscriptions> {
|
|
const render = batchedRenderFunctions.get(this);
|
|
return render ? getSubscriptions(render) : [];
|
|
}
|
|
}
|