diff --git a/doc/v3/owl/migration_owl2_to_owl3.md b/doc/v3/owl/migration_owl2_to_owl3.md
index eb875b355..ba47fc496 100644
--- a/doc/v3/owl/migration_owl2_to_owl3.md
+++ b/doc/v3/owl/migration_owl2_to_owl3.md
@@ -511,7 +511,7 @@ class C extends Component {
static template = xml`
...
`;
setup() {
- this.div = signal(null);
+ this.div = signal.ref();
onMounted(() => {
console.log(this.div());
});
diff --git a/doc/v3/owl/owl3_design.md b/doc/v3/owl/owl3_design.md
index 89ad4adc6..c814840ac 100644
--- a/doc/v3/owl/owl3_design.md
+++ b/doc/v3/owl/owl3_design.md
@@ -1258,7 +1258,7 @@ class C extends Component {
static template = xml`...
`;
setup() {
- this.ref = signal(null);
+ this.ref = signal.ref();
onMounted(() => {
console.log(this.ref());
});
@@ -1854,7 +1854,7 @@ class Editor extends Component {
`;
- editable = signal(null);
+ editable = signal.ref();
setup() {
providePlugins([ContentPlugin, SelectionPlugin, TextToolsPlugin, GEDPlugin], {
diff --git a/doc/v3/owl/reference/hooks.md b/doc/v3/owl/reference/hooks.md
index 76481ade1..c5714647e 100644
--- a/doc/v3/owl/reference/hooks.md
+++ b/doc/v3/owl/reference/hooks.md
@@ -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) {
@@ -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);
```
diff --git a/doc/v3/owl/reference/portal.md b/doc/v3/owl/reference/portal.md
index a05ffd9a2..c0f5bcd1c 100644
--- a/doc/v3/owl/reference/portal.md
+++ b/doc/v3/owl/reference/portal.md
@@ -24,7 +24,7 @@ class Page extends Component {
`;
- modalRoot = signal(null);
+ modalRoot = signal.ref();
}
```
diff --git a/doc/v3/owl/reference/reactivity.md b/doc/v3/owl/reference/reactivity.md
index bc6fa94e8..bca618a15 100644
--- a/doc/v3/owl/reference/reactivity.md
+++ b/doc/v3/owl/reference/reactivity.md
@@ -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` (or narrower if a constructor is given):
+
+```js
+class SomeComponent extends Component {
+ static template = xml``;
+
+ 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
diff --git a/doc/v3/owl/reference/refs.md b/doc/v3/owl/reference/refs.md
index 1c745d323..8eef4b632 100644
--- a/doc/v3/owl/reference/refs.md
+++ b/doc/v3/owl/reference/refs.md
@@ -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:
@@ -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();
@@ -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`. It optionally takes a constructor to narrow the
+element type:
+
+```ts
+inputRef = signal.ref(HTMLInputElement); // Signal
+```
+
+Any signal works as a `t-ref` target, so `signal(null)` is equivalent at
+runtime — but TypeScript would infer it as `Signal`, so prefer
+`signal.ref()`.
+
## Multiple References with Resources
When `t-ref` is used inside a loop, a single signal can only hold one element.
diff --git a/doc/v3/owl/reference/types_validation.md b/doc/v3/owl/reference/types_validation.md
index 19451447a..0ba60c544 100644
--- a/doc/v3/owl/reference/types_validation.md
+++ b/doc/v3/owl/reference/types_validation.md
@@ -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).
diff --git a/packages/owl-core/src/signal.ts b/packages/owl-core/src/signal.ts
index 595861404..d4eb62746 100644
--- a/packages/owl-core/src/signal.ts
+++ b/packages/owl-core/src/signal.ts
@@ -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 extends ReactiveValue {
/**
@@ -48,6 +49,17 @@ function triggerSignal(signal: Signal): 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` (or narrower if a constructor
+ * is given): `myRef = signal.ref()` or `inputRef = signal.ref(HTMLInputElement)`.
+ */
+function signalRef(): Signal;
+function signalRef>(type: T): Signal | null>;
+function signalRef(): Signal {
+ return buildSignal(null, (atom) => atom.value);
+}
+
function signalArray(initialValue: T[]): Signal;
function signalArray(initialValue: NoInfer[], options: SignalOptions): Signal;
function signalArray(initialValue: T[]): Signal {
@@ -90,6 +102,7 @@ export function signal(value: T): Signal {
return buildSignal(value, (atom) => atom.value);
}
signal.trigger = triggerSignal;
+signal.ref = signalRef;
signal.Array = signalArray;
signal.Map = signalMap;
signal.Object = signalObject;
diff --git a/packages/owl-core/src/types.ts b/packages/owl-core/src/types.ts
index 7315d362f..46f8be448 100644
--- a/packages/owl-core/src/types.ts
+++ b/packages/owl-core/src/types.ts
@@ -308,7 +308,10 @@ function reactiveValueType(type?: any): ReactiveValue {
function ref(): HTMLElement | null;
function ref>(type: T): InstanceType | 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 = {
diff --git a/packages/owl-core/tests/signals.test.ts b/packages/owl-core/tests/signals.test.ts
index 96b58c860..9fe8dce95 100644
--- a/packages/owl-core/tests/signals.test.ts
+++ b/packages/owl-core/tests/signals.test.ts
@@ -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([]);
diff --git a/packages/owl-core/tests/validation.test.ts b/packages/owl-core/tests/validation.test.ts
index d61c522c5..a5c09535a 100644
--- a/packages/owl-core/tests/validation.test.ts
+++ b/packages/owl-core/tests/validation.test.ts
@@ -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: "" },
diff --git a/packages/owl-runtime/tests/components/__snapshots__/refs.test.ts.snap b/packages/owl-runtime/tests/components/__snapshots__/refs.test.ts.snap
index 19eed745f..322bbf2cf 100644
--- a/packages/owl-runtime/tests/components/__snapshots__/refs.test.ts.snap
+++ b/packages/owl-runtime/tests/components/__snapshots__/refs.test.ts.snap
@@ -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(\`\`);
+
+ 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
) {
diff --git a/packages/owl-runtime/tests/components/refs.test.ts b/packages/owl-runtime/tests/components/refs.test.ts
index 9868307c8..03ffaff00 100644
--- a/packages/owl-runtime/tests/components/refs.test.ts
+++ b/packages/owl-runtime/tests/components/refs.test.ts
@@ -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 = 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``;
diff --git a/tools/playground/samples/canvas/main.js b/tools/playground/samples/canvas/main.js
index 7fe656aef..2a3fb59cf 100644
--- a/tools/playground/samples/canvas/main.js
+++ b/tools/playground/samples/canvas/main.js
@@ -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
diff --git a/tools/playground/samples/html_editor/html_editor/html_editor.js b/tools/playground/samples/html_editor/html_editor/html_editor.js
index 2b9baef1d..69cdf9034 100644
--- a/tools/playground/samples/html_editor/html_editor/html_editor.js
+++ b/tools/playground/samples/html_editor/html_editor/html_editor.js
@@ -7,7 +7,7 @@ export class HtmlEditor extends Component {
});
setup() {
- this.editorRef = signal(null);
+ this.editorRef = signal.ref();
onMounted(() => {
const el = this.editorRef();
diff --git a/tools/playground/samples/tutorials/hibou_os/11/drag_and_drop.js b/tools/playground/samples/tutorials/hibou_os/11/drag_and_drop.js
index c10276e25..c88fb2760 100644
--- a/tools/playground/samples/tutorials/hibou_os/11/drag_and_drop.js
+++ b/tools/playground/samples/tutorials/hibou_os/11/drag_and_drop.js
@@ -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();
diff --git a/tools/playground/samples/tutorials/hibou_os/11/readme.md b/tools/playground/samples/tutorials/hibou_os/11/readme.md
index fe799148e..b2d60adcc 100644
--- a/tools/playground/samples/tutorials/hibou_os/11/readme.md
+++ b/tools/playground/samples/tutorials/hibou_os/11/readme.md
@@ -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();
diff --git a/tools/playground/samples/tutorials/todo_list/10/todo_list_solution.js b/tools/playground/samples/tutorials/todo_list/10/todo_list_solution.js
index 62eb9fe7c..bacdb82e2 100644
--- a/tools/playground/samples/tutorials/todo_list/10/todo_list_solution.js
+++ b/tools/playground/samples/tutorials/todo_list/10/todo_list_solution.js
@@ -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]);
diff --git a/tools/playground/samples/tutorials/todo_list/4/readme.md b/tools/playground/samples/tutorials/todo_list/4/readme.md
index 3264aab51..e97f37a1a 100644
--- a/tools/playground/samples/tutorials/todo_list/4/readme.md
+++ b/tools/playground/samples/tutorials/todo_list/4/readme.md
@@ -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
@@ -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(() => {
diff --git a/tools/playground/samples/tutorials/todo_list/4/todo_list_solution.js b/tools/playground/samples/tutorials/todo_list/4/todo_list_solution.js
index d0a43b500..2a0bcf531 100644
--- a/tools/playground/samples/tutorials/todo_list/4/todo_list_solution.js
+++ b/tools/playground/samples/tutorials/todo_list/4/todo_list_solution.js
@@ -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 },
diff --git a/tools/playground/samples/tutorials/todo_list/5/main.js b/tools/playground/samples/tutorials/todo_list/5/main.js
index 78c735397..fab58ce51 100644
--- a/tools/playground/samples/tutorials/todo_list/5/main.js
+++ b/tools/playground/samples/tutorials/todo_list/5/main.js
@@ -13,7 +13,7 @@ class TodoList extends Component {
filter = signal("all");
editingId = signal(null);
editText = signal("");
- editInput = signal(null);
+ editInput = signal.ref();
setup() {
useEffect(
diff --git a/tools/playground/samples/tutorials/todo_list/5/todo_list_solution.js b/tools/playground/samples/tutorials/todo_list/5/todo_list_solution.js
index b85ddc7d4..612c1ca9b 100644
--- a/tools/playground/samples/tutorials/todo_list/5/todo_list_solution.js
+++ b/tools/playground/samples/tutorials/todo_list/5/todo_list_solution.js
@@ -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) },
diff --git a/tools/playground/samples/tutorials/todo_list/6/main.js b/tools/playground/samples/tutorials/todo_list/6/main.js
index 26d0cace1..80616cb32 100644
--- a/tools/playground/samples/tutorials/todo_list/6/main.js
+++ b/tools/playground/samples/tutorials/todo_list/6/main.js
@@ -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() {
diff --git a/tools/playground/samples/tutorials/todo_list/6/todo_list_solution.js b/tools/playground/samples/tutorials/todo_list/6/todo_list_solution.js
index 04d2d9f03..42bce69db 100644
--- a/tools/playground/samples/tutorials/todo_list/6/todo_list_solution.js
+++ b/tools/playground/samples/tutorials/todo_list/6/todo_list_solution.js
@@ -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) },
diff --git a/tools/playground/samples/tutorials/todo_list/7/todo_list_solution.js b/tools/playground/samples/tutorials/todo_list/7/todo_list_solution.js
index a1ad88724..cad4738e6 100644
--- a/tools/playground/samples/tutorials/todo_list/7/todo_list_solution.js
+++ b/tools/playground/samples/tutorials/todo_list/7/todo_list_solution.js
@@ -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) },
diff --git a/tools/playground/samples/tutorials/todo_list/8/todo_list_solution.js b/tools/playground/samples/tutorials/todo_list/8/todo_list_solution.js
index 62eb9fe7c..bacdb82e2 100644
--- a/tools/playground/samples/tutorials/todo_list/8/todo_list_solution.js
+++ b/tools/playground/samples/tutorials/todo_list/8/todo_list_solution.js
@@ -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]);