Skip to content
Merged
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
22 changes: 5 additions & 17 deletions doc/v3/owl/owl3_design.md
Original file line number Diff line number Diff line change
Expand Up @@ -1618,34 +1618,22 @@ understand what is going on in a template. Also, it may make it harder for
static tooling to work (in this case, the type of all signals is basically
erased from the content of the template).

### Simplified way to define a prop
### Static prop helper

It is often useful to only import a single prop:
It is often useful to only import a single component prop:

```js
class TodoItem extends Component {
todo = props({ todo: t.instanceOf(Todo) }).todo;
}

class MyPlugin extends Plugin {
editable = plugin.props({ editable: t.signal() }).editable;
}
```

Maybe it would be worth it to have a special simplified syntax for this usecase:

```js
class TodoItem extends Component {
todo = prop("todo", t.instanceOf(Todo));
}

class MyPlugin extends Plugin {
editable = plugin.prop("editable", t.signal());
todo = props.static("todo", t.instanceOf(Todo));
}
```

It's nicer and remove the repetition, but at the cost of adding yet another
primitive function in Owl.
The `props.static()` form keeps the static-prop semantics without adding a
separate top-level primitive function in Owl.

## Examples

Expand Down
2 changes: 1 addition & 1 deletion doc/v3/owl/reference/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Here is a list of everything exported by the Owl library.
- [`mount`](app.md#mount-helper): mount a component to a DOM target
- [`xml`](template_syntax.md#inline-templates): define an inline template
- [`props`](props.md): declare and validate component props
- [`prop`](props.md#the-prop-function): declare a single static prop on a component
- [`props.static`](props.md#the-propsstatic-method): declare a single static prop on a component
- [`status`](component.md#status-helper): get the status of a component (new, mounted, destroyed)

## Reactivity
Expand Down
28 changes: 14 additions & 14 deletions doc/v3/owl/reference/props.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,72 +119,72 @@ reading `this.props.value` inside the effect subscribes the effect to future
updates of that prop.

This only applies to props accessed through `props()`. The singular
[`prop()`](#the-prop-function) helper keeps its static, reference-stable
[`props.static()`](#the-propsstatic-method) helper keeps its static, reference-stable
semantics.

## The `prop` function
## The `props.static` method

`prop` (singular) is an alternative for components that only need to read a
`props.static` is an alternative for components that only need to read a
single prop and expect that prop to stay **static** — meaning the reference
passed by the parent does not change across renders. It takes the prop name
explicitly, an optional [type validator](types_validation.md#validators),
and an optional default value. If the type is omitted, the prop is accepted
as-is without validation.

```js
import { Component, prop, types as t, xml } from "@odoo/owl";
import { Component, props, types as t, xml } from "@odoo/owl";

class TodoView extends Component {
static template = xml`<div t-out="this.todo.title"/>`;
todo = prop("todo", t.instanceOf(Todo));
todo = props.static("todo", t.instanceOf(Todo));
}

class Header extends Component {
static template = xml`<h1 t-out="this.label"/>`;
label = prop("label", t.string(), "untitled");
label = props.static("label", t.string(), "untitled");
}

class Passthrough extends Component {
static template = xml`<div t-out="this.payload"/>`;
payload = prop("payload"); // no type: any value accepted
payload = props.static("payload"); // no type: any value accepted
}
```

Each `prop()` call declares one prop. The value is read once, at component
Each `props.static()` call declares one prop. The value is read once, at component
construction time, and assigned directly to the class field — so `this.todo`
is the `Todo` instance, not a getter or accessor.

### Static semantics

In [dev mode](app.md#configuration), `prop()` does two things:
In [dev mode](app.md#configuration), `props.static()` does two things:

- validates the initial value against the declared type,
- registers a check that throws if the prop's reference changes on a
subsequent parent render.

This makes `prop()` a good fit for values the child treats as identity-stable
This makes `props.static()` a good fit for values the child treats as identity-stable
(`instanceOf` models, event buses, or [signals](reactivity.md#signals)). If a
[signal](reactivity.md#signals) is passed, the signal object itself stays the
same across renders even as its inner value updates — which is exactly what
`prop()` requires.
`props.static()` requires.

In production mode, the type check and the immutability check are skipped
entirely.

### When to use `prop()` vs `props()`
### When to use `props.static()` vs `props()`

Use `props()` when the child needs to observe prop changes (the parent
re-renders with a new value and the child must reflect it), when computed values
or effects should react to prop changes, or when the child declares several
props at once.

Use `prop()` when:
Use `props.static()` when:

- the child only needs one (or a few) specific props,
- the prop is conceptually static — a model instance, a signal, an event bus,
- you want dev mode to flag unexpected reference changes.

`prop()` and `props()` can be mixed freely in the same component.
`props.static()` and `props()` can be mixed freely in the same component.

## Translatable props

Expand Down
1 change: 0 additions & 1 deletion packages/owl-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ export { Portal } from "./portal";
export { Suspense } from "./suspense";
export { props } from "./props";
export type { GetProps } from "./props";
export { prop } from "./prop";
export { status } from "./status";
export {
asyncComputed,
Expand Down
10 changes: 5 additions & 5 deletions packages/owl-runtime/src/prop.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { assertType, OwlError } from "@odoo/owl-core";
import { getComponentScope } from "./component_node";

export function prop<T = any>(key: string): T;
export function prop<T>(key: string, type: T): T;
export function prop<T>(key: string, type: T, defaultValue: T): T;
export function prop(key: string, type?: any, ...args: any[]): any {
export function staticProp<T = any>(key: string): T;
export function staticProp<T>(key: string, type: T): T;
export function staticProp<T>(key: string, type: T, defaultValue: T): T;
export function staticProp(key: string, type?: any, ...args: any[]): any {
const node = getComponentScope();
const hasDefault = args.length > 0;
const propValue = node.props[key];
Expand All @@ -17,7 +17,7 @@ export function prop(key: string, type?: any, ...args: any[]): any {
if (nextProps[key] !== node.props[key]) {
throw new OwlError(
`Prop '${key}' changed in component '${node.componentName}'. ` +
`Props declared with \`prop()\` are static and should not change. ` +
`Props declared with \`props.static()\` are static and should not change. ` +
`If the prop is a signal, pass the same signal reference (its inner value may change).`
);
}
Expand Down
37 changes: 24 additions & 13 deletions packages/owl-runtime/src/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Signal,
} from "@odoo/owl-core";
import { getComponentScope } from "./component_node";
import { staticProp } from "./prop";
import { types } from "./types";

function validateDefaults(schema: Record<string, any> | string[]) {
Expand Down Expand Up @@ -44,18 +45,22 @@ export type GetProps<T> = {
? { [K in keyof I]: I[K] }
: never;

export function props(): Props<Record<string, any>>;
export function props<const Keys extends string[]>(keys: Keys): Props<ResolveObjectType<Keys>>;
export function props<const Keys extends string[], Defaults>(
keys: Keys,
defaults: Defaults & GetPropsDefaults<KeyedObject<Keys>>
): Props<WithDefaults<ResolveObjectType<Keys>, Defaults>>;
export function props<Shape extends {}>(shape: Shape): Props<ResolveObjectType<Shape>>;
export function props<Shape extends {}, Defaults>(
shape: Shape,
defaults: Defaults & GetPropsDefaults<Shape>
): Props<WithDefaults<ResolveObjectType<Shape>, Defaults>>;
export function props(type?: any, defaults?: any): Props<{}> {
export interface PropsFunction {
(): Props<Record<string, any>>;
<const Keys extends string[]>(keys: Keys): Props<ResolveObjectType<Keys>>;
<const Keys extends string[], Defaults>(
keys: Keys,
defaults: Defaults & GetPropsDefaults<KeyedObject<Keys>>
): Props<WithDefaults<ResolveObjectType<Keys>, Defaults>>;
<Shape extends {}>(shape: Shape): Props<ResolveObjectType<Shape>>;
<Shape extends {}, Defaults>(
shape: Shape,
defaults: Defaults & GetPropsDefaults<Shape>
): Props<WithDefaults<ResolveObjectType<Shape>, Defaults>>;
static: typeof staticProp;
}

function makeProps(type?: any, defaults?: any): Props<{}> {
const node = getComponentScope();
const { app, componentName } = node;
if (defaults) {
Expand Down Expand Up @@ -101,7 +106,11 @@ export function props(type?: any, defaults?: any): Props<{}> {

if (app.dev) {
if (defaults) {
assertType(defaults, validateDefaults(type), `Invalid component default props (${componentName})`);
assertType(
defaults,
validateDefaults(type),
`Invalid component default props (${componentName})`
);
}

const validation = types.object(type);
Expand Down Expand Up @@ -151,3 +160,5 @@ export function props(type?: any, defaults?: any): Props<{}> {

return result;
}

export const props = Object.assign(makeProps, { static: staticProp }) as PropsFunction;
2 changes: 1 addition & 1 deletion packages/owl-runtime/src/rendering/template_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ function createComponent<P extends Record<string, any>>(
if (hooks.length) {
// Defaults must reach the hooks but must NOT be stored on node.props:
// otherwise the next arePropsDifferent call sees ghost diffs on default
// keys and re-renders on every parent render. Consumers (`prop`/`props`)
// keys and re-renders on every parent render. Consumers (`props.static`/`props`)
// already resolve defaults lazily from raw node.props.
let nextProps = props;
const defaultProps = node.defaultProps;
Expand Down
37 changes: 18 additions & 19 deletions packages/owl-runtime/tests/components/prop.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, mount, prop, proxy, signal, types as t, xml } from "../../src";
import { Component, mount, props, proxy, signal, types as t, xml } from "../../src";
import {
getConsoleOutput,
makeTestFixture,
Expand All @@ -24,7 +24,7 @@ describe("basics", () => {
test("reads the named prop", async () => {
class Child extends Component {
static template = xml`<span><t t-out="this.value"/></span>`;
value = prop("value", t.number());
value = props.static("value", t.number());
}
class Parent extends Component {
static template = xml`<div><Child value="42"/></div>`;
Expand All @@ -38,7 +38,7 @@ describe("basics", () => {
test("default value when prop is absent", async () => {
class Child extends Component {
static template = xml`<span><t t-out="this.label"/></span>`;
label = prop("label", t.string(), "untitled");
label = props.static("label", t.string(), "untitled");
}
class Parent extends Component {
static template = xml`<div><Child/></div>`;
Expand All @@ -52,7 +52,7 @@ describe("basics", () => {
test("provided value takes precedence over default", async () => {
class Child extends Component {
static template = xml`<span><t t-out="this.label"/></span>`;
label = prop("label", t.string(), "untitled");
label = props.static("label", t.string(), "untitled");
}
class Parent extends Component {
static template = xml`<div><Child label="'hello'"/></div>`;
Expand All @@ -66,8 +66,8 @@ describe("basics", () => {
test("multiple prop fields in same component", async () => {
class Child extends Component {
static template = xml`<span><t t-out="this.a"/>/<t t-out="this.b"/></span>`;
a = prop("a", t.string());
b = prop("b", t.number());
a = props.static("a", t.string());
b = props.static("b", t.number());
}
class Parent extends Component {
static template = xml`<div><Child a="'x'" b="2"/></div>`;
Expand All @@ -79,11 +79,10 @@ describe("basics", () => {
});

test("can be mixed with props()", async () => {
const { props } = await import("../../src");
class Child extends Component {
static template = xml`<span><t t-out="this.all.extra"/>/<t t-out="this.main"/></span>`;
all = props({ "extra?": t.string() });
main = prop("main", t.number());
main = props.static("main", t.number());
}
class Parent extends Component {
static template = xml`<div><Child main="7" extra="'side'"/></div>`;
Expand All @@ -97,7 +96,7 @@ describe("basics", () => {
test("type argument is optional (accepts any value)", async () => {
class Child extends Component {
static template = xml`<span><t t-out="this.value"/></span>`;
value = prop("value");
value = props.static("value");
}
class Parent extends Component {
static template = xml`<div><Child value="'anything'"/></div>`;
Expand All @@ -117,7 +116,7 @@ describe("basics", () => {
}
class Child extends Component {
static template = xml`<span><t t-out="this.todo.name"/></span>`;
todo = prop("todo", t.instanceOf(Todo));
todo = props.static("todo", t.instanceOf(Todo));
}
class Parent extends Component {
static template = xml`<div><Child todo="this.myTodo"/></div>`;
Expand All @@ -138,7 +137,7 @@ describe("dev mode", () => {
test("validates initial type on mount (root component)", async () => {
class Root extends Component {
static template = xml`<div/>`;
value = prop("value", t.number());
value = props.static("value", t.number());
}

let error: any;
Expand All @@ -155,7 +154,7 @@ describe("dev mode", () => {
test("validates initial type on mount (child component)", async () => {
class Child extends Component {
static template = xml`<div/>`;
value = prop("value", t.number());
value = props.static("value", t.number());
}
class Parent extends Component {
static template = xml`<Child value="'not-a-number'"/>`;
Expand All @@ -176,7 +175,7 @@ describe("dev mode", () => {
class Todo {}
class Child extends Component {
static template = xml`<div/>`;
todo = prop("todo", t.instanceOf(Todo));
todo = props.static("todo", t.instanceOf(Todo));
}
class Parent extends Component {
static template = xml`<Child todo="this.state.todo"/>`;
Expand All @@ -197,7 +196,7 @@ describe("dev mode", () => {

class Child extends Component {
static template = xml`<div/>`;
todo = prop("todo", t.signal());
todo = props.static("todo", t.signal());
}
class Parent extends Component {
static template = xml`<Child todo="this.todoSig"/>`;
Expand All @@ -218,7 +217,7 @@ describe("dev mode", () => {
test("skips validation in non-dev mode", async () => {
class Root extends Component {
static template = xml`<div/>`;
value = prop("value", t.number());
value = props.static("value", t.number());
}

let error: any;
Expand All @@ -234,7 +233,7 @@ describe("dev mode", () => {
class Todo {}
class Child extends Component {
static template = xml`<div/>`;
todo = prop("todo", t.instanceOf(Todo));
todo = props.static("todo", t.instanceOf(Todo));
}
class Parent extends Component {
static template = xml`<Child todo="this.state.todo"/>`;
Expand All @@ -253,7 +252,7 @@ describe("dev mode", () => {
test("default value skips validation when prop is absent", async () => {
class Root extends Component {
static template = xml`<div t-out="this.label"/>`;
label = prop("label", t.string(), "fallback");
label = props.static("label", t.string(), "fallback");
}

// No error even though prop is missing: default covers it
Expand All @@ -264,7 +263,7 @@ describe("dev mode", () => {
test("omitted prop with default does not trigger static-prop error on re-render", async () => {
class Child extends Component {
static template = xml`<div t-out="this.label"/>`;
label = prop("label", t.string(), "fallback");
label = props.static("label", t.string(), "fallback");
}
class Parent extends Component {
static template = xml`<Child/>`;
Expand All @@ -289,7 +288,7 @@ describe("dev mode", () => {
test("throws if called outside a component scope", () => {
let error: any;
try {
prop("foo", t.string());
props.static("foo", t.string());
} catch (e) {
error = e;
}
Expand Down
Loading