Skip to content
Draft
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
23 changes: 23 additions & 0 deletions doc/v3/owl/reference/props.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,29 @@ wrong to use `.alike`.
</t>
```

### Alike props and effects

Props coming from `useProps` are reactive, so reading `this.props.someProp` in an
[effect](effects.md) or a [computed value](computed_values.md) subscribes to it.
Alike props are left out of that too, and do not notify when the parent renders
again with a new function.

```js
class Child extends Component {
props = useProps(["value", "onClick"]);
setup() {
// runs again when `value` changes, but not because the parent rebuilt `onClick`
useEffect(() => {
console.log(this.props.value, this.props.onClick);
});
}
}
```

An anonymous function still notifies if one of the values it captures changes,
since that is also what makes Owl re-render the child. A `.alike` or `.bind` prop
captures nothing, so it never notifies.

## Binding function props

It is common to have the need to pass a callback as a prop. Since Owl components
Expand Down
12 changes: 10 additions & 2 deletions packages/owl-compiler/src/code_generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1164,6 +1164,9 @@ export class CodeGenerator {
const hasSlotsProp = "slots" in (ast.props || {});
const props: string[] = [];
const propList: string[] = [];
// the props left out of propList because they are always equivalent. The
// runtime needs that list too, to know which prop signals to leave alone.
const alikeProps: string[] = [];

for (let p in ast.props || {}) {
let [name, suffix] = p.split(".");
Expand All @@ -1178,8 +1181,11 @@ export class CodeGenerator {
}

if (suffix) {
// .alike, .bind, .translate — delegate to formatProp, no propList entry
props.push(this.formatProp(p, ast.props![p], ast.propsTranslationCtx, ctx.translationCtx));
// a .translate prop is a constant string, so its signal never really changes
if (suffix !== "translate") {
alikeProps.push(`"${name}"`);
}
continue;
}

Expand All @@ -1189,6 +1195,7 @@ export class CodeGenerator {
props.push(`${propName}: ${compiledValue || undefined}`);

if (freeVariables) {
alikeProps.push(`"${name}"`);
for (const varName of freeVariables) {
const syntheticKey = `\x01${name}.${varName}`;
propList.push(`"${syntheticKey}"`);
Expand Down Expand Up @@ -1268,11 +1275,12 @@ export class CodeGenerator {
}
let id = generateId("comp");
this.helpers.add("createComponent");
const alikeArg = alikeProps.length ? `, [${alikeProps}]` : "";
this.staticDefs.push({
id,
expr: `createComponent(app, ${
ast.isDynamic ? null : expr
}, ${!ast.isDynamic}, ${!!ast.slots}, ${!!ast.dynamicProps}, [${propList}])`,
}, ${!ast.isDynamic}, ${!!ast.slots}, ${!!ast.dynamicProps}, [${propList}]${alikeArg})`,
});

if (ast.isDynamic) {
Expand Down
7 changes: 6 additions & 1 deletion packages/owl-runtime/src/component_node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export class ComponentNode extends Scope implements VNode<ComponentNode> {
parentKey: string | null;
props: Record<string, any>;
defaultProps: Record<string, any> | null = null;
// set by createComponent, read by useProps. Only here to carry the list from
// the template down to the hook.
alikeProps: Set<string> | null;
renderFn!: Function;
parent: ComponentNode | null;
children: { [key: string]: ComponentNode } = Object.create(null);
Expand All @@ -58,11 +61,13 @@ export class ComponentNode extends Scope implements VNode<ComponentNode> {
props: Record<string, any>,
app: App,
parent: ComponentNode | null,
parentKey: string | null
parentKey: string | null,
alikeProps: Set<string> | null = null
) {
super(app);
this.parent = parent;
this.parentKey = parentKey;
this.alikeProps = alikeProps;
this.pluginManager = parent ? parent.pluginManager : app.pluginManager;
this.componentName = C.name;
this.signalComputation = createComputation(
Expand Down
45 changes: 44 additions & 1 deletion packages/owl-runtime/src/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@ export interface PropsFunction {
static: typeof staticProp;
}

function collectCaptureKeys(props: Record<string, any>): Map<string, string[]> | null {
let byProp: Map<string, string[]> | null = null;
for (const key in props) {
if (key.charCodeAt(0) !== 1) {
continue;
}
byProp ||= new Map();
const name = key.slice(1, key.indexOf("."));
const keys = byProp.get(name);
if (keys) {
keys.push(key);
} else {
byProp.set(name, [key]);
}
}
return byProp;
}

function makeProps(type?: any): Props<{}> {
const node = getComponentScope();
const { app, componentName } = node;
Expand Down Expand Up @@ -71,6 +89,26 @@ function makeProps(type?: any): Props<{}> {
return props[key];
}

const alikeProps = node.alikeProps;
const captureKeys = alikeProps ? collectCaptureKeys(node.props) : null;
let lastProps = node.props;

// an alike prop is a new function on every render, but it does the same thing
// unless one of the values it captured changed. `.alike` and `.bind` capture
// nothing, so they never change.
function capturesChanged(key: string, props: Record<string, any>) {
const keys = captureKeys?.get(key);
if (!keys) {
return false;
}
for (const capture of keys) {
if (lastProps[capture] !== props[capture]) {
return true;
}
}
return false;
}

const signals: Record<string, Signal<any>> = Object.create(null);
const result = Object.create(null);
function defineProp(key: string) {
Expand All @@ -89,9 +127,14 @@ function makeProps(type?: any): Props<{}> {
}

function updateSignals(keys: string[]) {
const props = node.props;
for (const key of keys) {
signals[key].set(resolveValue(node.props, key));
if (alikeProps !== null && alikeProps.has(key) && !capturesChanged(key, props)) {
continue;
}
signals[key].set(resolveValue(props, key));
}
lastProps = props;
}

if (type) {
Expand Down
8 changes: 6 additions & 2 deletions packages/owl-runtime/src/rendering/template_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,9 +240,13 @@ function createComponent<P extends Record<string, any>>(
isStatic: boolean,
hasSlotsProp: boolean,
hasDynamicPropList: boolean,
propList: string[]
propList: string[],
alikeProps?: string[]
) {
const isDynamic = !isStatic;
// undefined for templates compiled before this list existed, and empty for
// components that have no such prop
const alikePropSet = alikeProps?.length ? new Set(alikeProps) : null;
let arePropsDifferent: (p1: P, p2: P) => boolean;
const hasNoProp = propList.length === 0;
if (hasSlotsProp) {
Expand Down Expand Up @@ -351,7 +355,7 @@ function createComponent<P extends Record<string, any>>(
);
}
}
node = new ComponentNode(C, props, app, ctx, key);
node = new ComponentNode(C, props, app, ctx, key, alikePropSet);
children[key] = node;
const fiber = new Fiber(node, parentFiber);
if (node.willStart.length) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ exports[`basics > render from above on error -- handler is not a Root or MountFi
) {
let { text, createBlock, list, multi, html, toggler } = bdom;
let { createComponent } = helpers;
const comp1 = createComponent(app, \`Boom\`, true, false, false, []);
const comp1 = createComponent(app, \`Boom\`, true, false, false, [], ["onError"]);

let block1 = createBlock(\`<div><block-child-0/><block-child-1/></div>\`);

Expand Down Expand Up @@ -1156,7 +1156,7 @@ exports[`can catch errors > catching error, rethrow, render parent -- a main co
let { text, createBlock, list, multi, html, toggler } = bdom;
let { prepareList, createComponent, markRaw, withKey } = helpers;
const comp1 = createComponent(app, null, false, false, false, []);
const comp2 = createComponent(app, \`ErrorHandler\`, true, true, false, ["onError.cp"]);
const comp2 = createComponent(app, \`ErrorHandler\`, true, true, false, ["onError.cp"], ["onError"]);

function slot1(ctx, node, key = "") {
const Comp1 = ctx['cp'].Comp;
Expand Down Expand Up @@ -1234,7 +1234,7 @@ exports[`can catch errors > catching in child makes parent render 1`] = `
let { text, createBlock, list, multi, html, toggler } = bdom;
let { prepareList, createComponent, markRaw, withKey } = helpers;
const comp1 = createComponent(app, null, false, false, false, ["id"]);
const comp2 = createComponent(app, \`Catch\`, true, true, false, ["onError.elem"]);
const comp2 = createComponent(app, \`Catch\`, true, true, false, ["onError.elem"], ["onError"]);

function slot1(ctx, node, key = "") {
const Comp1 = ctx['elem'][1];
Expand Down
Loading