Files
owl/src/runtime/component_node.ts
T

356 lines
11 KiB
TypeScript
Raw Normal View History

2022-05-26 23:45:40 +02:00
import type { App, Env } from "./app";
import { BDom, VNode } from "./blockdom";
import { Component, ComponentConstructor, Props } from "./component";
import { fibersInError, handleError } from "./error_handling";
import { Fiber, makeChildFiber, makeRootFiber, MountFiber, MountOptions } from "./fibers";
import {
clearReactivesForCallback,
getSubscriptions,
NonReactive,
Reactive,
reactive,
TARGET,
2022-05-26 23:45:40 +02:00
} from "./reactivity";
2020-11-26 16:45:25 +01:00
import { STATUS } from "./status";
import { batched, Callback } from "./utils";
2020-11-26 16:45:25 +01:00
2021-12-20 17:32:32 +01:00
let currentNode: ComponentNode | null = null;
export function getCurrent(): ComponentNode {
if (!currentNode) {
throw new Error("No active component (a hook function should only be called in 'setup')");
}
2021-12-20 17:32:32 +01:00
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) {
2022-06-03 15:35:04 +02:00
if (props[propName] === undefined) {
(props as any)[propName] = defaultProps[propName];
}
}
}
2022-01-10 13:49:12 +01:00
// -----------------------------------------------------------------------------
// 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): Reactive<T> | NonReactive<T> {
const node = getCurrent();
let render = batchedRenderFunctions.get(node)!;
if (!render) {
render = batched(node.render.bind(node, false));
2022-01-10 13:49:12 +01:00
batchedRenderFunctions.set(node, render);
// manual implementation of onWillDestroy to break cyclic dependency
node.willDestroy.push(clearReactivesForCallback.bind(null, render));
}
return reactive(state, render);
}
2020-11-26 16:45:25 +01:00
// -----------------------------------------------------------------------------
2022-01-10 13:49:12 +01:00
// Component VNode class
2020-11-26 16:45:25 +01:00
// -----------------------------------------------------------------------------
type LifecycleHook = Function;
export class ComponentNode<P extends Props = any, E = any> implements VNode<ComponentNode<P, E>> {
2020-11-26 16:45:25 +01:00
el?: HTMLElement | Text | undefined;
app: App;
fiber: Fiber | null = null;
component: Component<P, E>;
2020-11-26 16:45:25 +01:00
bdom: BDom | null = null;
status: STATUS = STATUS.NEW;
forceNextRender: boolean = false;
parentKey: string | null;
2022-06-03 15:35:04 +02:00
props: P;
2020-11-26 16:45:25 +01:00
renderFn: Function;
2021-11-02 13:53:44 +01:00
parent: ComponentNode | null;
level: number;
childEnv: Env;
2020-11-26 16:45:25 +01:00
children: { [key: string]: ComponentNode } = Object.create(null);
refs: any = {};
willStart: LifecycleHook[] = [];
willUpdateProps: LifecycleHook[] = [];
willUnmount: LifecycleHook[] = [];
mounted: LifecycleHook[] = [];
willPatch: LifecycleHook[] = [];
patched: LifecycleHook[] = [];
willDestroy: LifecycleHook[] = [];
2020-11-26 16:45:25 +01:00
constructor(
C: ComponentConstructor<P, E>,
props: P,
app: App,
parent: ComponentNode | null,
parentKey: string | null
) {
2020-11-26 16:45:25 +01:00
currentNode = this;
this.app = app;
this.parent = parent;
2022-06-03 15:35:04 +02:00
this.props = props;
this.parentKey = parentKey;
2021-11-02 13:53:44 +01:00
this.level = parent ? parent.level + 1 : 0;
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" && prop[TARGET]) {
props[key] = useState(prop);
}
}
this.component = new C(props, env, this);
this.renderFn = app.getTemplate(C.template).bind(this.component, this.component, this);
2020-11-26 16:45:25 +01:00
this.component.setup();
currentNode = null;
2020-11-26 16:45:25 +01:00
}
mountComponent(target: any, options?: MountOptions) {
const fiber = new MountFiber(this, target, options);
2020-11-26 16:45:25 +01:00
this.app.scheduler.addFiber(fiber);
this.initiateRender(fiber);
}
async initiateRender(fiber: Fiber | MountFiber) {
2021-11-25 11:13:48 +01:00
this.fiber = fiber;
2020-11-26 16:45:25 +01:00
if (this.mounted.length) {
fiber.root!.mounted.push(fiber);
2020-11-26 16:45:25 +01:00
}
const component = this.component;
try {
await Promise.all(this.willStart.map((f) => f.call(component)));
} catch (e) {
handleError({ node: this, error: e });
return;
}
2020-11-26 16:45:25 +01:00
if (this.status === STATUS.NEW && this.fiber === fiber) {
fiber.render();
2020-11-26 16:45:25 +01:00
}
}
async render(deep: boolean) {
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) {
2020-11-26 16:45:25 +01:00
return;
}
2021-11-25 10:47:50 +01:00
const fiber = makeRootFiber(this);
fiber.deep = deep;
2021-11-25 11:13:48 +01:00
this.fiber = fiber;
2020-11-26 16:45:25 +01:00
this.app.scheduler.addFiber(fiber);
await Promise.resolve();
if (this.status === STATUS.DESTROYED) {
return;
}
2021-11-25 10:47:50 +01:00
// 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();
2020-11-26 16:45:25 +01:00
}
}
destroy() {
let shouldRemove = this.status === STATUS.MOUNTED;
this._destroy();
if (shouldRemove) {
2020-11-26 16:45:25 +01:00
this.bdom!.remove();
}
}
2020-11-26 16:45:25 +01:00
_destroy() {
const component = this.component;
if (this.status === STATUS.MOUNTED) {
for (let cb of this.willUnmount) {
2020-11-26 16:45:25 +01:00
cb.call(component);
}
}
for (let child of Object.values(this.children)) {
child._destroy();
2020-11-26 16:45:25 +01:00
}
if (this.willDestroy.length) {
try {
for (let cb of this.willDestroy) {
cb.call(component);
}
} catch (e) {
handleError({ error: e, node: this });
}
}
this.status = STATUS.DESTROYED;
2020-11-26 16:45:25 +01:00
}
async updateAndRender(props: P, parentFiber: Fiber) {
2022-06-03 15:35:04 +02:00
const rawProps = props;
props = Object.assign({}, props);
2020-11-26 16:45:25 +01:00
// update
const fiber = makeChildFiber(this, parentFiber);
2021-11-25 11:13:48 +01:00
this.fiber = fiber;
2020-11-26 16:45:25 +01:00
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" && prop[TARGET]) {
props[key] = useState(prop);
}
}
currentNode = null;
2020-11-26 16:45:25 +01:00
const prom = Promise.all(this.willUpdateProps.map((f) => f.call(component, props)));
await prom;
if (fiber !== this.fiber) {
return;
}
component.props = props;
2022-06-03 15:35:04 +02:00
this.props = rawProps;
fiber.render();
const parentRoot = parentFiber.root!;
if (this.willPatch.length) {
parentRoot.willPatch.push(fiber);
}
if (this.patched.length) {
parentRoot.patched.push(fiber);
}
2020-11-26 16:45:25 +01:00
}
2021-11-30 08:14:01 +01:00
/**
* 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;
}
2021-11-30 08:14:01 +01:00
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;
}
}
2020-11-26 16:45:25 +01:00
// ---------------------------------------------------------------------------
// 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;
2020-11-26 16:45:25 +01:00
this.fiber = null;
}
moveBefore(other: ComponentNode | null, afterNode: Node | null) {
this.bdom!.moveBefore(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();
}
}
_patch() {
2022-06-09 13:29:28 +02:00
let hasChildren = false;
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;
2020-11-26 16:45:25 +01:00
this.fiber = null;
}
beforeRemove() {
this._destroy();
2020-11-26 16:45:25 +01:00
}
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) : [];
}
2020-11-26 16:45:25 +01:00
}