Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[IMP] lazy reactive computed value #1499

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/runtime/computed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Reactive, Target, multiReactive, toRaw } from "./reactivity";

/**
* Creates a lazy reactive computed value.
*
* Calling the resulting function on the target not only returns the computed value,
* it also caches the result in the target. As a result, succeeding function calls
* will not trigger recalculation. And because of the reactivity system, the cached
* value will be invalidated when any of the dependencies of the compute function
* changes.
*
* Aside from caching, the computation is part of the reactivity system. This means
* that it plays well with rerendering. For example, having the following tree,
* `<Root><A/><B/></Root>`, where `A` reads from a computed value, when the computed
* value changes (or the dependencies of the computed value changes), only the
* components that read from the computed value will rerender. In this case, only
* `A` will rerender.
*
* Note that this is only valid for one target and one compute function.
* Use `computed` for shared compute functions.
*/
export function defineComputed(compute: (target: any) => any, name?: string) {
// This is the key that will be used to store the compute value in the target.
const cacheKey = name ? Symbol(name) : Symbol();
let isValid = false;
const invalidate = () => (isValid = false);
return (target: any) => {
if (isValid) {
// Return the cached value if it is still valid.
// This will subscribe the target's reactive directly to the cached value.
return target[cacheKey];
} else {
// Create a target with multiple reactives.
// - First is the original target's reactive.
// - Second is the invalidate function.
// This means that when any of the dependencies of the compute function changes,
// the invalidate function and the original target's reactive will be notified.
const mTarget = multiReactive(target, invalidate);
// Call the compute function on the multi-reactive target.
// This will subscribe the reactives to the dependencies of the compute function.
const value = compute(mTarget);
isValid = true;
try {
return value;
} finally {
// Right after return, the value is cached in the target.
// This will notify the subscribers of this computed value.
target[cacheKey] = value;
}
}
};
}

// map: target -> compute -> cached compute
const t2c2cc = new WeakMap();

/**
* This allows sharing of a declared computed such that for each target-compute
* combination, there is a corresponding cached computed function.
*/
export function computed<T extends Target, R>(
compute: (target: T | Reactive<T>) => R,
name?: string
) {
return (target: T | Reactive<T>): R => {
const raw = toRaw(target);
let c2cc = t2c2cc.get(raw);
if (!c2cc) {
c2cc = new Map();
t2c2cc.set(raw, c2cc);
}
let cachedCompute = c2cc.get(compute);
if (!cachedCompute) {
cachedCompute = defineComputed(compute, name);
c2cc.set(compute, cachedCompute);
}
return cachedCompute(target);
};
}
1 change: 1 addition & 0 deletions src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type { ComponentConstructor } from "./component";
export { useComponent, useState } from "./component_node";
export { status } from "./status";
export { reactive, markRaw, toRaw } from "./reactivity";
export { computed } from "./computed";
export { useEffect, useEnv, useExternalListener, useRef, useChildSubEnv, useSubEnv } from "./hooks";
export { EventBus, whenReady, loadFile, markup } from "./utils";
export {
Expand Down
38 changes: 34 additions & 4 deletions src/runtime/reactivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ const NO_CALLBACK = () => {

// The following types only exist to signify places where objects are expected
// to be reactive or not, they provide no type checking benefit over "object"
type Target = object;
type Reactive<T extends Target> = T;
export type Target = object;
export type Reactive<T extends Target> = T;

type Collection = Set<any> | Map<any, any> | WeakMap<any, any>;
type CollectionRawType = "Set" | "Map" | "WeakMap";
Expand Down Expand Up @@ -107,6 +107,19 @@ function observeTargetKey(target: Target, key: PropertyKey, callback: Callback):
}
callbacksToTargets.get(callback)!.add(target);
}

function clearAndCall(callback: Callback) {
clearReactivesForCallback(callback);
if (callback instanceof Array) {
// Recursively clear and call all callback pairs.
for (const cb of callback) {
clearAndCall(cb);
}
} else {
callback();
}
}

/**
* Notify Reactives that are observing a given target that a key has changed on
* the target.
Expand All @@ -127,8 +140,7 @@ function notifyReactives(target: Target, key: PropertyKey): void {
}
// Loop on copy because clearReactivesForCallback will modify the set in place
for (const callback of [...callbacks]) {
clearReactivesForCallback(callback);
callback();
clearAndCall(callback);
}
}

Expand Down Expand Up @@ -176,6 +188,7 @@ export function getSubscriptions(callback: Callback) {
// Maps reactive objects to the underlying target
export const targets = new WeakMap<Reactive<Target>, Target>();
const reactiveCache = new WeakMap<Target, WeakMap<Callback, Reactive<Target>>>();
const proxyToCallback = new WeakMap<Reactive<Target>, Callback>();
/**
* Creates a reactive proxy for an object. Reading data on the reactive object
* subscribes to changes to the data. Writing data on the object will cause the
Expand Down Expand Up @@ -225,10 +238,27 @@ export function reactive<T extends Target>(target: T, callback: Callback = NO_CA
: basicProxyHandler<T>(callback);
const proxy = new Proxy(target, handler as ProxyHandler<T>) as Reactive<T>;
reactivesForTarget.set(callback, proxy);
proxyToCallback.set(proxy, callback);
targets.set(proxy, target);
}
return reactivesForTarget.get(callback) as Reactive<T>;
}

/**
* Creates a target that will notify multiple reactives when dependencies change.
*/
export function multiReactive<T extends Target>(
reactiveTarget: T | Reactive<T>,
callback: Callback
): T {
const existingCB = proxyToCallback.get(reactiveTarget);
if (existingCB && existingCB !== NO_CALLBACK) {
return reactive(reactiveTarget, [callback, existingCB]);
} else {
return reactive(reactiveTarget, callback);
}
}

/**
* Creates a basic proxy handler for regular objects and arrays.
*
Expand Down
6 changes: 3 additions & 3 deletions src/runtime/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { OwlError } from "../common/owl_error";
export type Callback = () => void;
export type Callback = (() => void) | [first: Callback, second: Callback];

/**
* Creates a batched version of a callback so that all calls to it in the same
Expand All @@ -8,9 +8,9 @@ export type Callback = () => void;
* @param callback the callback to batch
* @returns a batched version of the original callback
*/
export function batched(callback: Callback): Callback {
export function batched<Args extends any[]>(callback: (...args: Args) => any) {
let scheduled = false;
return async (...args) => {
return async (...args: Args) => {
if (!scheduled) {
scheduled = true;
await Promise.resolve();
Expand Down
Loading
Loading