From 8f23fccefcf2a97225feafdac8b713099851b314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Fri, 10 Apr 2026 15:25:24 +0200 Subject: [PATCH 1/5] [IMP] runtime: improve call stack We inline here a few methods again, to get a smaller call stack, which should make it more enjoyable to debug with owl code --- src/runtime/app.ts | 58 ++++++++++++++------------------- src/runtime/blockdom/index.ts | 8 +++-- src/runtime/component_node.ts | 13 +------- src/runtime/rendering/fibers.ts | 6 ++-- 4 files changed, 34 insertions(+), 51 deletions(-) diff --git a/src/runtime/app.ts b/src/runtime/app.ts index 4b978822f..5fab9d8af 100644 --- a/src/runtime/app.ts +++ b/src/runtime/app.ts @@ -6,7 +6,8 @@ import { PluginConstructor, PluginManager, startPlugins } from "./plugin_manager import { GetProps } from "./props"; import { proxy, toRaw } from "./reactivity/proxy"; import { nodeErrorHandlers } from "./rendering/error_handling"; -import { Fiber, MountOptions, RootFiber } from "./rendering/fibers"; +import { getCurrentComputation, setComputation } from "./reactivity/computations"; +import { Fiber, MountFiber, MountOptions, RootFiber } from "./rendering/fibers"; import { Scheduler } from "./rendering/scheduler"; import { Resource } from "./resource"; import { TemplateSet, TemplateSetConfig } from "./template_set"; @@ -45,7 +46,7 @@ declare global { } } -type MountTarget = HTMLElement | ShadowRoot; +import type { MountTarget } from "./blockdom"; interface Root { node: ComponentNode; @@ -111,12 +112,32 @@ export class App extends TemplateSet { const root = { node: node!, promise, - mount: (target: HTMLElement | ShadowRoot, options?: MountOptions) => { + mount: (target: MountTarget, options?: MountOptions) => { if (error) { return promise; } App.validateTarget(target); - this.mountNode(node, target, resolve, reject, options); + + // Set up error handler and onMounted callback + let handlers = nodeErrorHandlers.get(node); + if (!handlers) { + handlers = []; + nodeErrorHandlers.set(node, handlers); + } + handlers.unshift((e, finalize) => { + const finalError = finalize(); + reject(finalError); + }); + node.mounted.push(() => { + resolve(node.component); + handlers!.shift(); + }); + + const fiber = new MountFiber(node, target, options); + this.scheduler.addFiber(fiber); + const prev = getCurrentComputation(); + node.initiateRender(fiber); + setComputation(prev); return promise; }, destroy: () => { @@ -129,35 +150,6 @@ export class App extends TemplateSet { return root; } - private mountNode( - node: ComponentNode, - target: HTMLElement | ShadowRoot, - resolve: (c: any) => void, - reject: (e: any) => void, - options?: MountOptions - ) { - // Manually add the last resort error handler on the node - let handlers = nodeErrorHandlers.get(node); - if (!handlers) { - handlers = []; - nodeErrorHandlers.set(node, handlers); - } - - handlers.unshift((e, finalize) => { - const finalError = finalize(); - reject(finalError); - }); - - // manually set a onMounted callback. - // that way, we are independant from the current node. - node.mounted.push(() => { - resolve(node.component); - handlers!.shift(); - }); - - node.mountComponent(target, options); - } - destroy() { for (let root of this.roots) { root.destroy(); diff --git a/src/runtime/blockdom/index.ts b/src/runtime/blockdom/index.ts index 4e29c44dc..2f6d6cb65 100644 --- a/src/runtime/blockdom/index.ts +++ b/src/runtime/blockdom/index.ts @@ -8,9 +8,11 @@ export { text, comment } from "./text"; export { html } from "./html"; export { createCatcher } from "./event_catcher"; +export type MountTarget = HTMLElement | ShadowRoot; + export interface VNode { - mount(parent: HTMLElement, afterNode: Node | null): void; - moveBeforeDOMNode(node: Node | null, parent?: HTMLElement): void; + mount(parent: MountTarget, afterNode: Node | null): void; + moveBeforeDOMNode(node: Node | null, parent?: MountTarget): void; moveBeforeVNode(other: T | null, afterNode: Node | null): void; patch(other: T, withBeforeRemove: boolean): void; beforeRemove(): void; @@ -25,7 +27,7 @@ export interface VNode { export type BDom = VNode; -export function mount(vnode: VNode, fixture: HTMLElement, afterNode: Node | null = null) { +export function mount(vnode: VNode, fixture: MountTarget, afterNode: Node | null = null) { vnode.mount(fixture, afterNode); } diff --git a/src/runtime/component_node.ts b/src/runtime/component_node.ts index e4d301d94..d63082367 100644 --- a/src/runtime/component_node.ts +++ b/src/runtime/component_node.ts @@ -12,7 +12,7 @@ import { setComputation, } from "./reactivity/computations"; import { fibersInError, handleError } from "./rendering/error_handling"; -import { Fiber, makeChildFiber, makeRootFiber, MountFiber, MountOptions } from "./rendering/fibers"; +import { Fiber, makeChildFiber, makeRootFiber, MountFiber } from "./rendering/fibers"; import { STATUS } from "./status"; // ----------------------------------------------------------------------------- @@ -85,17 +85,6 @@ export class ComponentNode implements VNode { contextStack.length = 0; // clear context stack } - mountComponent(target: any, options?: MountOptions) { - const fiber = new MountFiber(this, target, options); - this.app.scheduler.addFiber(fiber); - let prev = getCurrentComputation(); - this.initiateRender(fiber); - // only useful if the component is a root, and a willstart function just - // crashed synchonously. In that case, it is possible that the previous - // computation has not been properly restored - setComputation(prev); - } - async initiateRender(fiber: Fiber | MountFiber) { this.fiber = fiber; if (this.mounted.length) { diff --git a/src/runtime/rendering/fibers.ts b/src/runtime/rendering/fibers.ts index 293240633..9d817d23f 100644 --- a/src/runtime/rendering/fibers.ts +++ b/src/runtime/rendering/fibers.ts @@ -1,5 +1,5 @@ import { OwlError } from "../../common/owl_error"; -import { BDom, mount } from "../blockdom"; +import { BDom, mount, type MountTarget } from "../blockdom"; import type { ComponentNode } from "../component_node"; import { getCurrentComputation, removeSources, setComputation } from "../reactivity/computations"; import { STATUS } from "../status"; @@ -235,10 +235,10 @@ export interface MountOptions { } export class MountFiber extends RootFiber { - target: HTMLElement; + target: MountTarget; position: Position; - constructor(node: ComponentNode, target: HTMLElement, options: MountOptions = {}) { + constructor(node: ComponentNode, target: MountTarget, options: MountOptions = {}) { super(node, null); this.target = target; this.position = options.position || "last-child"; From ed942b10fce799391e97628492ade5a7441a2bb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Fri, 10 Apr 2026 16:15:51 +0200 Subject: [PATCH 2/5] [IMP] components: make initial component sync if possible --- src/runtime/app.ts | 18 ++- tests/app/sub_root.test.ts | 1 - tests/components/error_handling.test.ts | 21 +-- tests/components/props_validation.test.ts | 151 +++++++++------------- tests/components/reactivity.test.ts | 4 +- tests/components/refs.test.ts | 6 +- tests/helpers.ts | 24 ++-- 7 files changed, 106 insertions(+), 119 deletions(-) diff --git a/src/runtime/app.ts b/src/runtime/app.ts index 5fab9d8af..ab4740e85 100644 --- a/src/runtime/app.ts +++ b/src/runtime/app.ts @@ -135,9 +135,21 @@ export class App extends TemplateSet { const fiber = new MountFiber(node, target, options); this.scheduler.addFiber(fiber); - const prev = getCurrentComputation(); - node.initiateRender(fiber); - setComputation(prev); + if (node.willStart.length) { + const prev = getCurrentComputation(); + node.initiateRender(fiber); + setComputation(prev); + } else { + node.fiber = fiber; + if (node.mounted.length) { + fiber.root!.mounted.push(fiber); + } + try { + fiber.render(); + } catch (e) { + reject(e); + } + } return promise; }, destroy: () => { diff --git a/tests/app/sub_root.test.ts b/tests/app/sub_root.test.ts index b56c674ef..a7c7d896d 100644 --- a/tests/app/sub_root.test.ts +++ b/tests/app/sub_root.test.ts @@ -114,7 +114,6 @@ test("destroy a subroot while another component is mounted in main app", async ( const app = new App(); const comp = await app.createRoot(SomeComponent).mount(fixture); - expect(fixture.innerHTML).toBe("a
"); await nextTick(); expect(fixture.innerHTML).toBe("a
c
"); comp.state.flag = true; diff --git a/tests/components/error_handling.test.ts b/tests/components/error_handling.test.ts index a20bae1ac..7cfe3f117 100644 --- a/tests/components/error_handling.test.ts +++ b/tests/components/error_handling.test.ts @@ -321,11 +321,12 @@ describe("errors and promises", () => { const app = new App(); let error: OwlError; - const mountProm = app - .createRoot(Root) + const root = app.createRoot(Root); + const errorProm = nextAppError(app); + const mountProm = root .mount(fixture) .catch((e: Error) => (error = e)); - await expect(nextAppError(app)).resolves.toThrow( + await expect(errorProm).resolves.toThrow( "[Owl] Unhandled error. Destroying the root component" ); await mountProm; @@ -347,11 +348,12 @@ describe("errors and promises", () => { const app = new App({ test: true }); let error: OwlError; - const mountProm = app - .createRoot(Root) + const root = app.createRoot(Root); + const errorProm = nextAppError(app); + const mountProm = root .mount(fixture) .catch((e: Error) => (error = e)); - await expect(nextAppError(app)).resolves.toThrow( + await expect(errorProm).resolves.toThrow( "[Owl] Unhandled error. Destroying the root component" ); await mountProm; @@ -375,11 +377,12 @@ describe("errors and promises", () => { const app = new App({ test: true }); let error: OwlError; - const mountProm = app - .createRoot(Root) + const root = app.createRoot(Root); + const errorProm = nextAppError(app); + const mountProm = root .mount(fixture) .catch((e: Error) => (error = e)); - await expect(nextAppError(app)).resolves.toThrow( + await expect(errorProm).resolves.toThrow( "[Owl] Unhandled error. Destroying the root component" ); await mountProm; diff --git a/tests/components/props_validation.test.ts b/tests/components/props_validation.test.ts index afa0cefd4..8539ec8c4 100644 --- a/tests/components/props_validation.test.ts +++ b/tests/components/props_validation.test.ts @@ -1,6 +1,5 @@ -import { Component, mount, onError, OwlError, props, types as t, xml } from "../../src"; -import { App } from "../../src/runtime/app"; -import { makeTestFixture, nextAppError, nextTick, render, snapshotEverything } from "../helpers"; +import { Component, mount, onError, props, types as t, xml } from "../../src"; +import { makeTestFixture, nextTick, render, snapshotEverything } from "../helpers"; let fixture: HTMLElement; @@ -47,26 +46,22 @@ describe("props validation", () => { static template = xml`
`; } - const app = new App({ test: true }); - let error: OwlError | undefined; - const mountProm = app - .createRoot(Parent) - .mount(fixture) - .catch((e: Error) => (error = e)); - await expect(nextAppError(app)).resolves.toThrow( - "[Owl] Unhandled error. Destroying the root component" - ); - await mountProm; - expect(error!).toBeDefined(); - expect(error!.cause.message).toMatch("Invalid component props (SubComp)"); - error = undefined; + let error: any; + try { + await mount(Parent, fixture, { test: true }); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.cause.message).toMatch("Invalid component props (SubComp)"); + error = undefined; try { await mount(Parent, fixture, { dev: false }); } catch (e) { - error = e as Error; + error = e; } - expect(error!).toBeUndefined(); + expect(error).toBeUndefined(); }); test("props: list of strings", async () => { @@ -79,18 +74,14 @@ describe("props validation", () => { static template = xml`
`; } - const app = new App({ test: true }); - let error: OwlError | undefined; - const mountProm = app - .createRoot(Parent) - .mount(fixture) - .catch((e: Error) => (error = e)); - await expect(nextAppError(app)).resolves.toThrow( - "[Owl] Unhandled error. Destroying the root component" - ); - await mountProm; - expect(error!).toBeDefined(); - expect(error!.cause.message).toMatch("Invalid component props (SubComp)"); + let error: any; + try { + await mount(Parent, fixture, { test: true }); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.cause.message).toMatch("Invalid component props (SubComp)"); }); test("validate props for root component", async () => { @@ -132,39 +123,35 @@ describe("props validation", () => { }; (Parent as any).components = { SubComp }; + let error: any; + state = {}; - let app = new App({ test: true }); - let error: OwlError | undefined; - let mountProm = app - .createRoot(Parent) - .mount(fixture) - .catch((e: Error) => (error = e)); - await expect(nextAppError(app)).resolves.toThrow( - "[Owl] Unhandled error. Destroying the root component" - ); - await mountProm; - expect(error!).toBeDefined(); - expect(error!.cause.message).toMatch(`Invalid component props (SubComp)`); + try { + await mount(Parent, fixture, { test: true }); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.cause.message).toMatch(`Invalid component props (SubComp)`); + error = undefined; state = { p: test.ok }; try { await mount(Parent, fixture, { dev: true }); } catch (e) { - error = e as Error; + error = e; } - expect(error!).toBeUndefined(); + expect(error).toBeUndefined(); + + error = undefined; state = { p: test.ko }; - app = new App({ test: true }); - mountProm = app - .createRoot(Parent) - .mount(fixture) - .catch((e: Error) => (error = e)); - await expect(nextAppError(app)).resolves.toThrow( - "[Owl] Unhandled error. Destroying the root component" - ); - await mountProm; - expect(error!).toBeDefined(); - expect(error!.cause.message).toMatch(`Invalid component props (SubComp)`); + try { + await mount(Parent, fixture, { test: true }); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.cause.message).toMatch(`Invalid component props (SubComp)`); } }); @@ -197,17 +184,13 @@ describe("props validation", () => { } expect(error!).toBeUndefined(); state = { p: 1 }; - const app = new App({ test: true }); - const mountProm = app - .createRoot(Parent) - .mount(fixture) - .catch((e: Error) => (error = e)); - await expect(nextAppError(app)).resolves.toThrow( - "[Owl] Unhandled error. Destroying the root component" - ); - await mountProm; - expect(error!).toBeDefined(); - expect(error!.cause.message).toMatch("Invalid component props (SubComp)"); + try { + await mount(Parent, fixture, { test: true }); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.cause.message).toMatch("Invalid component props (SubComp)"); }); test("can validate an optional props", async () => { @@ -239,17 +222,13 @@ describe("props validation", () => { } expect(error!).toBeUndefined(); state = { p: 1 }; - const app = new App({ test: true }); - const mountProm = app - .createRoot(Parent) - .mount(fixture) - .catch((e: Error) => (error = e)); - await expect(nextAppError(app)).resolves.toThrow( - "[Owl] Unhandled error. Destroying the root component" - ); - await mountProm; - expect(error!).toBeDefined(); - expect(error!.cause.message).toMatch("Invalid component props (SubComp)"); + try { + await mount(Parent, fixture, { test: true }); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.cause.message).toMatch("Invalid component props (SubComp)"); }); test("can validate an array with given primitive type", async () => { @@ -332,17 +311,13 @@ describe("props validation", () => { } expect(error!).toBeUndefined(); state = { p: [true, 1] }; - const app = new App({ test: true }); - const mountProm = app - .createRoot(Parent) - .mount(fixture) - .catch((e: Error) => (error = e)); - await expect(nextAppError(app)).resolves.toThrow( - "[Owl] Unhandled error. Destroying the root component" - ); - await mountProm; - expect(error!).toBeDefined(); - expect(error!.cause.message).toMatch("Invalid component props (SubComp)"); + try { + await mount(Parent, fixture, { test: true }); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.cause.message).toMatch("Invalid component props (SubComp)"); }); test("can validate an object with simple shape", async () => { diff --git a/tests/components/reactivity.test.ts b/tests/components/reactivity.test.ts index 589c9f86b..4433e2999 100644 --- a/tests/components/reactivity.test.ts +++ b/tests/components/reactivity.test.ts @@ -185,7 +185,9 @@ describe("reactivity in lifecycle", () => { const prom = mount(Comp, fixture); (STATE as any).val = 2; await prom; - expect(steps).toEqual([2]); + // The first render happens synchronously (fast path, no willStart), + // so the template executes with val=1. Then val=2 triggers a re-render. + expect(steps).toEqual([1, 2]); expect(fixture.innerHTML).toBe("
2
"); }); diff --git a/tests/components/refs.test.ts b/tests/components/refs.test.ts index 08b4ea2b7..e753b7893 100644 --- a/tests/components/refs.test.ts +++ b/tests/components/refs.test.ts @@ -131,10 +131,12 @@ describe("refs", () => { } const app = new App({ test: true }); - const mountProm = expect(app.createRoot(Test).mount(fixture)).rejects.toThrow( + const root = app.createRoot(Test); + const errorProm = nextAppError(app); + const mountProm = expect(root.mount(fixture)).rejects.toThrow( 'Cannot set the same ref more than once in the same component, ref "coucou" was set multiple times in Test' ); - await expect(nextAppError(app)).resolves.toThrow( + await expect(errorProm).resolves.toThrow( 'Cannot set the same ref more than once in the same component, ref "coucou" was set multiple times in Test' ); await mountProm; diff --git a/tests/helpers.ts b/tests/helpers.ts index 678022719..15acb307f 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -277,27 +277,21 @@ expect.extend({ }); export function nextAppError(app: any) { - const { _handleError } = app; - const rootPromises = [...app.roots].map((r) => r.promise); - - let settled = false; - - const done = (error: any, restore = true) => { - if (settled) return; - settled = true; - if (restore) app._handleError = _handleError; - resolve(error); - }; - let resolve: (value: any) => void; const result = new Promise((res) => (resolve = res)); + const original = app._handleError; app._handleError = (error: any) => { - done(error); + app._handleError = original; + resolve(error); }; - for (const p of rootPromises) { - p.catch((err: any) => done(err)); + // Also catch rejections from root mount promises + for (const root of app.roots) { + root.promise.catch((err: any) => { + app._handleError = original; + resolve(err); + }); } return result; From e7d3ef222501e26429c25a3b6a9d436a2b82d1bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Fri, 10 Apr 2026 16:33:52 +0200 Subject: [PATCH 3/5] wip --- src/runtime/app.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/runtime/app.ts b/src/runtime/app.ts index ab4740e85..fea5d66aa 100644 --- a/src/runtime/app.ts +++ b/src/runtime/app.ts @@ -6,7 +6,6 @@ import { PluginConstructor, PluginManager, startPlugins } from "./plugin_manager import { GetProps } from "./props"; import { proxy, toRaw } from "./reactivity/proxy"; import { nodeErrorHandlers } from "./rendering/error_handling"; -import { getCurrentComputation, setComputation } from "./reactivity/computations"; import { Fiber, MountFiber, MountOptions, RootFiber } from "./rendering/fibers"; import { Scheduler } from "./rendering/scheduler"; import { Resource } from "./resource"; @@ -136,9 +135,7 @@ export class App extends TemplateSet { const fiber = new MountFiber(node, target, options); this.scheduler.addFiber(fiber); if (node.willStart.length) { - const prev = getCurrentComputation(); node.initiateRender(fiber); - setComputation(prev); } else { node.fiber = fiber; if (node.mounted.length) { From c42fd2787e1461d5f419dff86a670df043789d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Fri, 10 Apr 2026 16:36:43 +0200 Subject: [PATCH 4/5] wip --- tests/app/sub_root.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/app/sub_root.test.ts b/tests/app/sub_root.test.ts index a7c7d896d..5911aa4bc 100644 --- a/tests/app/sub_root.test.ts +++ b/tests/app/sub_root.test.ts @@ -114,7 +114,8 @@ test("destroy a subroot while another component is mounted in main app", async ( const app = new App(); const comp = await app.createRoot(SomeComponent).mount(fixture); - await nextTick(); + // With the sync fast path, the sub-root's child C (no willStart) renders + // synchronously during onMounted, so it's already in the DOM. expect(fixture.innerHTML).toBe("a
c
"); comp.state.flag = true; await nextTick(); From 7d35fb58e6d18791e481f63ce5c254f4a449fbf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Fri, 10 Apr 2026 16:41:57 +0200 Subject: [PATCH 5/5] wip --- tests/components/error_handling.test.ts | 63 ++++++++++--------------- 1 file changed, 24 insertions(+), 39 deletions(-) diff --git a/tests/components/error_handling.test.ts b/tests/components/error_handling.test.ts index 7cfe3f117..6ff16728f 100644 --- a/tests/components/error_handling.test.ts +++ b/tests/components/error_handling.test.ts @@ -1,5 +1,4 @@ import { App, Component, mount, onWillDestroy, props, types } from "../../src"; -import { OwlError } from "../../src/common/owl_error"; import { onError, onMounted, @@ -319,18 +318,14 @@ describe("errors and promises", () => { } } - const app = new App(); - let error: OwlError; - const root = app.createRoot(Root); - const errorProm = nextAppError(app); - const mountProm = root - .mount(fixture) - .catch((e: Error) => (error = e)); - await expect(errorProm).resolves.toThrow( - "[Owl] Unhandled error. Destroying the root component" - ); - await mountProm; - expect(error!).toBeDefined(); + let error: any; + try { + await mount(Root, fixture); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.message).toBe("[Owl] Unhandled error. Destroying the root component"); expect(fixture.innerHTML).toBe(""); expect(mockConsoleError).toHaveBeenCalledTimes(0); expect(mockConsoleWarn).toHaveBeenCalledTimes(0); @@ -346,19 +341,14 @@ describe("errors and promises", () => { } } - const app = new App({ test: true }); - let error: OwlError; - const root = app.createRoot(Root); - const errorProm = nextAppError(app); - const mountProm = root - .mount(fixture) - .catch((e: Error) => (error = e)); - await expect(errorProm).resolves.toThrow( - "[Owl] Unhandled error. Destroying the root component" - ); - await mountProm; - expect(error!).toBeDefined(); - expect(error!.cause.stack).toContain("error_handling.test.ts"); + let error: any; + try { + await mount(Root, fixture, { test: true }); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.cause.stack).toContain("error_handling.test.ts"); expect(fixture.innerHTML).toBe(""); expect(mockConsoleError).toHaveBeenCalledTimes(0); expect(mockConsoleWarn).toHaveBeenCalledTimes(0); @@ -375,19 +365,14 @@ describe("errors and promises", () => { } } - const app = new App({ test: true }); - let error: OwlError; - const root = app.createRoot(Root); - const errorProm = nextAppError(app); - const mountProm = root - .mount(fixture) - .catch((e: Error) => (error = e)); - await expect(errorProm).resolves.toThrow( - "[Owl] Unhandled error. Destroying the root component" - ); - await mountProm; - expect(error!).toBeDefined(); - expect(error!.cause.message).toBe(`boom in onWillStart`); + let error: any; + try { + await mount(Root, fixture, { test: true }); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.cause.message).toBe("boom in onWillStart"); }); test("an error in willPatch call will reject the render promise", async () => {