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]);