mirror of
https://github.com/odoo/owl.git
synced 2025-10-06 19:59:41 +07:00
7538aeae0e
Before this commit, most of the time, components are destroyed when the
virtual dom is patched and a component node is removed. However, since
Owl is asynchronous and a component may take some time to get ready
(with onWillStart), it can happen that a component is created, then
before it is ready, it is recreated. In that case, the initial instance
has to be destroyed.
Before this commit, the destroy operation was done immediately, when we
cancel the current fibers. However, this means that we cannot have a
guarantee between micro task ticks that a component has not been
destroyed in the meantime.
For example, in Odoo, it is common to use the rpc service, which will
throw an error if called by a destroyed component. But because of the
possible destruction of a component at any time, the following code is
unsafe:
async loadSomeData() {
// guaranteed to be called when component is alive
await Promise.resolve();
// however here, component may have been destroyed
this.rpc(...)
}
So, to prevent this issue, we can slightly delay the destroy operation.
It is not entirely trivial, since we need to find a way to neutralize
the component in the meantime. But it seems like performing all that
kind of operation at the "commit" phase (so, the request animation frame
callback) makes sense to me.
So, this commit modifies the code to add a new component status
(cancelled) and use it to cancel components that are waiting to be
destroyed. These components will then be destroyed as soon as the
requestanimation frame starts, before all other dom operations.
384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
import type { App, Env } from "./app";
|
|
import { BDom, VNode } from "./blockdom";
|
|
import { Component, ComponentConstructor, Props } from "./component";
|
|
import { fibersInError, OwlError } from "./error_handling";
|
|
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) : [];
|
|
}
|
|
}
|