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
2 changes: 1 addition & 1 deletion doc/v3/owl/migration_owl2_to_owl3.md
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,7 @@ class C extends Component {
static template = xml`<div t-ref="this.div">...</div>`;

setup() {
this.div = signal(null);
this.div = signal.ref();
onMounted(() => {
console.log(this.div());
});
Expand Down
4 changes: 2 additions & 2 deletions doc/v3/owl/owl3_design.md
Original file line number Diff line number Diff line change
Expand Up @@ -1258,7 +1258,7 @@ class C extends Component {
static template = xml`<div t-ref="this.ref">...</div>`;

setup() {
this.ref = signal(null);
this.ref = signal.ref();
onMounted(() => {
console.log(this.ref());
});
Expand Down Expand Up @@ -1854,7 +1854,7 @@ class Editor extends Component {
</div>
</div>`;

editable = signal(null);
editable = signal.ref();

setup() {
providePlugins([ContentPlugin, SelectionPlugin, TextToolsPlugin, GEDPlugin], {
Expand Down
4 changes: 2 additions & 2 deletions doc/v3/owl/reference/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Here is an example of a `useAutofocus` hook built with `useEffect`:

```js
function useAutofocus() {
const ref = signal(null);
const ref = signal.ref();
useEffect(() => {
const el = ref();
if (el) {
Expand Down Expand Up @@ -126,7 +126,7 @@ reference:
useListener(window, "click", this.closeMenu, { capture: true });

// Listen on a ref signal — effect-based, re-attaches when element changes
const ref = signal(null);
const ref = signal.ref();
useListener(ref, "scroll", this.onScroll);
```

Expand Down
2 changes: 1 addition & 1 deletion doc/v3/owl/reference/portal.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class Page extends Component {
<div t-ref="this.modalRoot"/>
`;

modalRoot = signal(null);
modalRoot = signal.ref();
}
```

Expand Down
16 changes: 16 additions & 0 deletions doc/v3/owl/reference/reactivity.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,22 @@ list().push({ nested: { value: 2 } });
list()[0].nested.value = 42;
```

### Ref Signals

`signal.ref()` creates a signal meant to receive a DOM element through the
`t-ref` directive. It starts at `null` and is typed as
`Signal<HTMLElement | null>` (or narrower if a constructor is given):

```js
class SomeComponent extends Component {
static template = xml`<input t-ref="this.inputRef"/>`;

inputRef = signal.ref(HTMLInputElement);
}
```

See [References](refs.md) for more details.

## Computed Values

A computed value is a lazily-evaluated derived value. It tracks its dependencies
Expand Down
20 changes: 16 additions & 4 deletions doc/v3/owl/reference/refs.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# References

References provide a way to interact with DOM elements rendered by a component.
In Owl 3, references are signal-based: you create a `signal(null)` and bind it
to a DOM node using the `t-ref` directive. The signal's value is the HTMLElement
when mounted, or `null` otherwise.
In Owl 3, references are signal-based: you create a signal with `signal.ref()`
and bind it to a DOM node using the `t-ref` directive. The signal's value is the
HTMLElement when mounted, or `null` otherwise.

As a short example, here is how we could set the focus on a given input:

Expand All @@ -16,7 +16,7 @@ As a short example, here is how we could set the focus on a given input:

```js
class SomeComponent extends Component {
inputRef = signal(null);
inputRef = signal.ref();

focusInput() {
this.inputRef()?.focus();
Expand All @@ -31,6 +31,18 @@ Note that this example uses the suffix `Ref` to name the reference. This
is not mandatory, but it is a useful convention, so we do not forget that it is
a reference signal.

`signal.ref()` is simply a signal starting at `null`, properly typed as
`Signal<HTMLElement | null>`. It optionally takes a constructor to narrow the
element type:

```ts
inputRef = signal.ref(HTMLInputElement); // Signal<HTMLInputElement | null>
```

Any signal works as a `t-ref` target, so `signal(null)` is equivalent at
runtime — but TypeScript would infer it as `Signal<null>`, so prefer
`signal.ref()`.

## Multiple References with Resources

When `t-ref` is used inside a loop, a single signal can only hold one element.
Expand Down
3 changes: 3 additions & 0 deletions doc/v3/owl/reference/types_validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,9 @@ t.ref(); // null | HTMLElement
t.ref(HTMLInputElement); // null | HTMLInputElement
```

Since it relies on `HTMLElement`, `t.ref()` throws when called in a non-DOM
environment (e.g. server-side rendering).

### `t.or(types)`

Validates that the value matches **at least one** of the given types (union).
Expand Down
13 changes: 13 additions & 0 deletions packages/owl-core/src/signal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { OwlError } from "./owl_error";
import { Atom, atomSymbol, onReadAtom, onWriteAtom, ReactiveValue } from "./computations";
import { proxifyTarget } from "./proxy";
import type { Constructor } from "./types";

export interface Signal<T> extends ReactiveValue<T> {
/**
Expand Down Expand Up @@ -48,6 +49,17 @@ function triggerSignal(signal: Signal<any>): void {
onWriteAtom((signal as any)[atomSymbol]);
}

/**
* Create a signal meant to receive an element through t-ref. It starts at null
* and is typed as `Signal<HTMLElement | null>` (or narrower if a constructor
* is given): `myRef = signal.ref()` or `inputRef = signal.ref(HTMLInputElement)`.
*/
function signalRef(): Signal<HTMLElement | null>;
function signalRef<T extends Constructor<HTMLElement>>(type: T): Signal<InstanceType<T> | null>;
function signalRef(): Signal<any> {
return buildSignal<any>(null, (atom) => atom.value);
}

function signalArray<T>(initialValue: T[]): Signal<T[]>;
function signalArray<T>(initialValue: NoInfer<T>[], options: SignalOptions<T>): Signal<T[]>;
function signalArray<T>(initialValue: T[]): Signal<T[]> {
Expand Down Expand Up @@ -90,6 +102,7 @@ export function signal<T>(value: T): Signal<T> {
return buildSignal<T>(value, (atom) => atom.value);
}
signal.trigger = triggerSignal;
signal.ref = signalRef;
signal.Array = signalArray;
signal.Map = signalMap;
signal.Object = signalObject;
Expand Down
5 changes: 4 additions & 1 deletion packages/owl-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,10 @@ function reactiveValueType(type?: any): ReactiveValue<any> {
function ref(): HTMLElement | null;
function ref<T extends Constructor<HTMLElement>>(type: T): InstanceType<T> | null;
function ref(type?: any): any {
return union([literalType(null), instanceType(type)]);
if (typeof HTMLElement === "undefined") {
throw new Error("Cannot use ref in a non-DOM environment");
}
return union([literalType(null), instanceType(type || HTMLElement)]);
}

export const types = {
Expand Down
20 changes: 20 additions & 0 deletions packages/owl-core/tests/signals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,26 @@ test("trigger a signal", async () => {
expect(() => signal.trigger(fakeSignal)).toThrow(/Value is not a signal/);
});

describe("signal.ref", () => {
test("starts at null and behaves like a plain signal", async () => {
const ref = signal.ref();
expect(ref()).toBe(null);

const e = spyEffect(() => ref());
e();
expectSpy(e.spy, 1, { result: null });

const el = {} as HTMLElement;
ref.set(el);
expect(ref()).toBe(el);
await waitScheduler();
expectSpy(e.spy, 2, { result: el });

ref.set(null);
expect(ref()).toBe(null);
});
});

describe("signal.Array", () => {
test("simple use", async () => {
const reactiveArray = signal.Array<number>([]);
Expand Down
45 changes: 45 additions & 0 deletions packages/owl-core/tests/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,51 @@ test("record", () => {
expect(validateType({ a: 123, b: 123 }, t.record(t.number()))).toEqual([]);
});

test("ref", () => {
// requires a DOM: throws when HTMLElement is not defined
expect(() => t.ref()).toThrow("Cannot use ref in a non-DOM environment");

class FakeHTMLElement {}
(globalThis as any).HTMLElement = FakeHTMLElement;
try {
class FakeHTMLDivElement extends FakeHTMLElement {}
const el = new FakeHTMLElement();

// no argument: accepts null or any HTMLElement (or subclass)
expect(validateType(null, t.ref())).toEqual([]);
expect(validateType(el, t.ref())).toEqual([]);
expect(validateType(new FakeHTMLDivElement(), t.ref())).toEqual([]);
expect(validateType(123, t.ref())).toEqual([
{
message: "value does not match union type",
path: "",
received: 123,
subIssues: [
{ message: "value is not equal to null", path: "", received: 123 },
{ message: "value is not an instance of 'FakeHTMLElement'", path: "", received: 123 },
],
},
]);

// with a constructor: narrows to that element type
expect(validateType(null, t.ref(FakeHTMLDivElement as any))).toEqual([]);
expect(validateType(new FakeHTMLDivElement(), t.ref(FakeHTMLDivElement as any))).toEqual([]);
expect(validateType(el, t.ref(FakeHTMLDivElement as any))).toEqual([
{
message: "value does not match union type",
path: "",
received: el,
subIssues: [
{ message: "value is not equal to null", path: "", received: el },
{ message: "value is not an instance of 'FakeHTMLDivElement'", path: "", received: el },
],
},
]);
} finally {
delete (globalThis as any).HTMLElement;
}
});

test("strictObject", () => {
expect(validateType("", t.strictObject({}))).toEqual([
{ message: "value is not an object", path: "", received: "" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@ exports[`refs > basic use 1`] = `
}"
`;

exports[`refs > basic use with signal.ref 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler } = bdom;
let { createRef } = helpers;

let block1 = createBlock(\`<input block-ref="0"/>\`);

return function template(ctx, node, key = "") {
let ref1 = createRef(ctx['this'].input);
return block1([ref1]);
}
}"
`;

exports[`refs > ref is set by child component 1`] = `
"function anonymous(app, bdom, helpers
) {
Expand Down
11 changes: 11 additions & 0 deletions packages/owl-runtime/tests/components/refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ describe("refs", () => {
expect(test.button()).toBe(fixture.firstChild);
});

test("basic use with signal.ref", async () => {
class Test extends Component {
static template = xml`<input t-ref="this.input"/>`;
input = signal.ref(HTMLInputElement);
}
const test = await mount(Test, fixture);
expect(test.input()).toBe(fixture.firstChild);
test.input()!.value = "test"; // typed as HTMLInputElement | null
expect((fixture.firstChild as HTMLInputElement).value).toBe("test");
});

test("refs are properly bound in slots", async () => {
class Dialog extends Component {
static template = xml`<span><t t-call-slot="footer"/></span>`;
Expand Down
2 changes: 1 addition & 1 deletion tools/playground/samples/canvas/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class CanvasPaint extends Component {
t-on-click="this.handleClick"/>`;

points = signal.Array([]);
canvas = signal(); // empty for now
canvas = signal.ref(); // empty for now

setup() {
// the draw method will read the canvas signal, so the effect
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export class HtmlEditor extends Component {
});

setup() {
this.editorRef = signal(null);
this.editorRef = signal.ref();

onMounted(() => {
const el = this.editorRef();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { signal, useEffect, useListener } from "@odoo/owl";

export function useDragAndDrop(x, y) {
const root = signal(null);
const handle = signal(null);
const root = signal.ref();
const handle = signal.ref();

useEffect(() => {
const el = root();
Expand Down
4 changes: 2 additions & 2 deletions tools/playground/samples/tutorials/hibou_os/11/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ handle:
import { signal, useEffect, useListener } from "@odoo/owl";

export function useDragAndDrop(x, y) {
const root = signal(null);
const handle = signal(null);
const root = signal.ref();
const handle = signal.ref();

useEffect(() => {
const el = root();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export class TodoList extends Component {
static template = "tutorial.TodoList";
static components = { TodoItem };

input = signal(null);
input = signal.ref();

setup() {
providePlugins([TodoListPlugin]);
Expand Down
4 changes: 2 additions & 2 deletions tools/playground/samples/tutorials/todo_list/4/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ hooks.
Here is what you need to do:

- Create a `signal` in the `TodoList` component to hold a reference to the
input element: `input = signal(null)`
input element: `input = signal.ref()`
- Bind it to the input element in the template using `t-ref="this.input"`
- Use the `onMounted` hook to read the signal and call `.focus()` on the
element
Expand All @@ -26,7 +26,7 @@ mounted, the signal contains the actual DOM element:
```js
import { signal, onMounted } from "@odoo/owl";

input = signal(null);
input = signal.ref();

setup() {
onMounted(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export class TodoList extends Component {
static components = { TodoItem };

nextId = 4;
input = signal(null);
input = signal.ref();

todos = signal.Array([
{ id: 1, text: "Buy milk", completed: false },
Expand Down
2 changes: 1 addition & 1 deletion tools/playground/samples/tutorials/todo_list/5/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class TodoList extends Component {
filter = signal("all");
editingId = signal(null);
editText = signal("");
editInput = signal(null);
editInput = signal.ref();

setup() {
useEffect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export class TodoList extends Component {
static components = { TodoItem };

nextId = 4;
input = signal(null);
input = signal.ref();

todos = signal.Array([
{ id: 1, text: "Buy milk", completed: signal(false) },
Expand Down
2 changes: 1 addition & 1 deletion tools/playground/samples/tutorials/todo_list/6/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ class Todo extends Component {
props = { todo: TodoItem };
todo = this.props.todo;
isEditing = signal(false);
input = signal(null);
input = signal.ref();
editText = signal(this.todo.text());

setup() {
Expand Down
Loading
Loading