diff --git a/packages/owl-runtime/src/app.ts b/packages/owl-runtime/src/app.ts index 7007bf31f..9b195773e 100644 --- a/packages/owl-runtime/src/app.ts +++ b/packages/owl-runtime/src/app.ts @@ -53,7 +53,13 @@ declare global { import type { MountTarget } from "./blockdom"; interface Root { - node: ComponentNode; + // The root ComponentNode. Null while waiting for the app's plugin manager to + // finish its async startup — instantiation is deferred so that Root.setup() + // observes a fully populated plugin state. Becomes non-null synchronously + // when plugins are already MOUNTED at createRoot time (the common case, + // including all sub-roots created via Portal / Suspense), and otherwise + // once `prepare()` has awaited `pluginManager.ready`. + node: ComponentNode | null; promise: Promise>; // Kick off rendering without a DOM target. Descendants' onWillStart fires // immediately and the bdom is built in memory. Idempotent — second call @@ -112,34 +118,43 @@ export class App extends TemplateSet { resolve = res; reject = rej; }); - let node: ComponentNode; - let error: any = null; - try { - node = new ComponentNode(Root, props, this, null, null); - } catch (e) { - error = e; - reject(e); - } - + let node: ComponentNode | null = null; let fiber: MountFiber | null = null; + let error: any = null; + let cancelled = false; let preparedPromise: Promise | null = null; - const prepare = (): Promise => { - if (preparedPromise) { - return preparedPromise; - } - if (error) { - return Promise.reject(error); + const instantiateNode = () => { + try { + node = new ComponentNode(Root, props, this, null, null); + } catch (e) { + error = e; + reject(e); } - fiber = new MountFiber(node, null); + }; + + // Fast path: when the plugin manager is already MOUNTED (no plugins, or + // they finished startup, or this is a sub-root from Portal / Suspense) + // build the node now so `root.node` is available synchronously. Otherwise + // defer until prepare() can await `pluginManager.ready`, so Root.setup() + // observes plugin state that has been populated by async onWillStart. + if (this.pluginManager.status >= STATUS.MOUNTED) { + instantiateNode(); + } + + // Render-startup logic, factored out so it can run either synchronously + // (fast path) or after awaiting plugins (deferred path). + const startRender = (): Promise => { + const n = node!; + fiber = new MountFiber(n, null); // Set up error handler. We install it at prepare() time so that errors // during the render phase (e.g. a descendant's onWillStart rejecting) // reject both `promise` (the mount result) and the prepared promise. - let handlers = nodeErrorHandlers.get(node); + let handlers = nodeErrorHandlers.get(n); if (!handlers) { handlers = []; - nodeErrorHandlers.set(node, handlers); + nodeErrorHandlers.set(n, handlers); } handlers.unshift((_, finalize) => { const finalError = finalize(); @@ -149,28 +164,22 @@ export class App extends TemplateSet { const ready = new Promise((res) => { fiber!.onPrepared = () => res(); }); - preparedPromise = ready; // Install the mount-resolve callback up front so the sync render path's // `if (node.mounted.length)` check sees it and registers the fiber in // root.mounted. Without this ordering the callback would never fire for // the commit-after-prepare sequence. - node.mounted.push(() => { - resolve(node.component); + n.mounted.push(() => { + resolve(n.component); handlers!.shift(); }); this.scheduler.addFiber(fiber); - if (this.pluginManager.status < STATUS.MOUNTED) { - // Plugins have pending onWillStart callbacks — await them before the - // root renders, so plugin state is populated during first render. - node.willStart.unshift(() => this.pluginManager.ready); - } - if (node.willStart.length) { - node.initiateRender(fiber); + if (n.willStart.length) { + n.initiateRender(fiber); } else { - node.fiber = fiber; - if (node.mounted.length) { + n.fiber = fiber; + if (n.mounted.length) { fiber.root!.mounted.push(fiber); } try { @@ -179,6 +188,36 @@ export class App extends TemplateSet { reject(e); } } + return ready; + }; + + const prepare = (): Promise => { + if (preparedPromise) { + return preparedPromise; + } + if (error) { + return Promise.reject(error); + } + if (node) { + preparedPromise = startRender(); + } else { + // Deferred path: wait for plugin startup, then construct the root and + // proceed. Rejection from a plugin's onWillStart propagates to the + // mount promise so callers see a single failure surface. + preparedPromise = this.pluginManager.ready.then( + () => { + if (cancelled) return; + instantiateNode(); + if (error) throw error; + return startRender(); + }, + (e) => { + if (cancelled) return; + reject(e); + throw e; + } + ); + } return preparedPromise; }; @@ -187,17 +226,50 @@ export class App extends TemplateSet { return promise; } App.validateTarget(target); - prepare(); - fiber!.commit(target, options); - return promise; + if (node) { + prepare(); + fiber!.commit(target, options); + return promise; + } + // Deferred path. We collapse instantiate + startRender + commit into a + // single microtask after `pluginManager.ready` so that the DOM mutation + // happens before any subsequent microtask (such as the awaiter of an + // outer mount() that finished synchronously). Multiple chained .then()s + // would otherwise let those continuations run first. + if (preparedPromise) { + // prepare() was already called externally — its chain owns the + // instantiate + startRender phase. Just commit when it lands. + return preparedPromise.then(() => { + if (cancelled || error || !fiber) return promise; + fiber.commit(target, options); + return promise; + }); + } + return this.pluginManager.ready.then( + () => { + if (cancelled) return promise; + instantiateNode(); + if (error) return promise; + preparedPromise = startRender(); + fiber!.commit(target, options); + return promise; + }, + (e) => { + if (!cancelled) reject(e); + return promise; + } + ); }; - const root = { - node: node!, + const root: Root = { + get node() { + return node; + }, promise, prepare, mount, destroy: () => { + cancelled = true; this.roots.delete(root); node?.destroy(); this.scheduler.processTasks(); diff --git a/packages/owl-runtime/src/portal.ts b/packages/owl-runtime/src/portal.ts index 6efbafea0..ce0538d4d 100644 --- a/packages/owl-runtime/src/portal.ts +++ b/packages/owl-runtime/src/portal.ts @@ -44,17 +44,23 @@ export class Portal extends Component { root = app.createRoot(PortalContent, { props: { slots } } as any); + // Sub-roots from Portal are created while their parent is being + // rendered, i.e. after the app's plugin manager has reached MOUNTED, so + // `createRoot` takes the synchronous fast path and `root.node` is + // available immediately. (See the comment on Root.node in app.ts.) + const subNode = root.node!; + // Forward the plugin chain from this Portal (same pattern as Suspense: // createRoot defaults sub-roots to the app-level plugin manager; we // override so `providePlugins` contributions from ancestors are visible // inside the portaled content). - root.node.pluginManager = portalNode.pluginManager; + subNode.pluginManager = portalNode.pluginManager; // Route errors from the portaled subtree back through Portal's parent // chain so consumer `onError` handlers still catch them. Without this, // sub-root errors would propagate to app._handleError and tear down // the whole app. - nodeErrorHandlers.set(root.node, [forwardErrorToParent(portalNode)]); + nodeErrorHandlers.set(subNode, [forwardErrorToParent(portalNode)]); root.mount(target); diff --git a/packages/owl-runtime/src/suspense.ts b/packages/owl-runtime/src/suspense.ts index 5e16f8123..6feade677 100644 --- a/packages/owl-runtime/src/suspense.ts +++ b/packages/owl-runtime/src/suspense.ts @@ -48,15 +48,21 @@ export class Suspense extends Component { props: { slots: this.props.slots }, } as any); + // Suspense is itself rendered as part of the outer tree, so the app's + // plugin manager has already reached MOUNTED by the time we get here — + // `createRoot` takes the synchronous fast path and `root.node` is + // available immediately. (See the comment on Root.node in app.ts.) + const subNode = root.node!; + // Thread the plugin manager so `providePlugins` contributions from // ancestors are visible inside the default slot. (createRoot defaults // sub-roots to the app-level plugin manager; override here.) Destroy // cascade is handled explicitly below via `onWillDestroy`. - root.node.pluginManager = suspenseNode.pluginManager; + subNode.pluginManager = suspenseNode.pluginManager; // Route errors from the sub-root back into Suspense's parent chain so // consumer `onError` handlers still catch descendant failures. - nodeErrorHandlers.set(root.node, [forwardErrorToParent(suspenseNode)]); + nodeErrorHandlers.set(subNode, [forwardErrorToParent(suspenseNode)]); // Kick off the render phase now — descendants' onWillStart fires in // parallel with the outer tree's mount, no target needed yet. @@ -65,7 +71,7 @@ export class Suspense extends Component { // Sync fast path: if the sub-root's render phase finished synchronously // (no pending onWillStart in the subtree), flip `prepared` *now* so the // first render skips the fallback entirely — no flash. - const fiber = root.node.fiber as MountFiber | null; + const fiber = subNode.fiber as MountFiber | null; if (fiber && fiber.counter === 0) { this.prepared.set(true); } diff --git a/packages/owl-runtime/tests/components/__snapshots__/plugins.test.ts.snap b/packages/owl-runtime/tests/components/__snapshots__/plugins.test.ts.snap index 1d51bc590..6f6d749a1 100644 --- a/packages/owl-runtime/tests/components/__snapshots__/plugins.test.ts.snap +++ b/packages/owl-runtime/tests/components/__snapshots__/plugins.test.ts.snap @@ -111,10 +111,9 @@ exports[`components mounted by plugin 1`] = ` "function anonymous(app, bdom, helpers ) { let { text, createBlock, list, multi, html, toggler } = bdom; - let { safeOutput } = helpers; return function template(ctx, node, key = "") { - return safeOutput(ctx['this'].p.value); + return text(\`abc\`); } }" `; @@ -123,9 +122,10 @@ exports[`components mounted by plugin 2`] = ` "function anonymous(app, bdom, helpers ) { let { text, createBlock, list, multi, html, toggler } = bdom; + let { safeOutput } = helpers; return function template(ctx, node, key = "") { - return text(\`abc\`); + return safeOutput(ctx['this'].p.value); } }" `; diff --git a/packages/owl-runtime/tests/components/plugins.test.ts b/packages/owl-runtime/tests/components/plugins.test.ts index d81852887..8716ca8a7 100644 --- a/packages/owl-runtime/tests/components/plugins.test.ts +++ b/packages/owl-runtime/tests/components/plugins.test.ts @@ -433,5 +433,9 @@ test("components mounted by plugin", async () => { } await mount(R, fixture, { plugins: [P] }); - expect(fixture.innerHTML).toBe("defabc"); + // R is mounted synchronously by the outer mount() call; R2's mount, kicked + // off inside P.setup() while the plugin manager is still starting up, waits + // for `pluginManager.ready` before instantiating its node — so it ends up + // appended after R in the shared fixture. + expect(fixture.innerHTML).toBe("abcdef"); }); diff --git a/packages/owl-runtime/tests/plugins.test.ts b/packages/owl-runtime/tests/plugins.test.ts index dfaed6df5..1d5c88027 100644 --- a/packages/owl-runtime/tests/plugins.test.ts +++ b/packages/owl-runtime/tests/plugins.test.ts @@ -756,6 +756,264 @@ describe("onWillStart in plugins", () => { rpc.resolve(0); }); + test("root.setup() observes loaded global async plugin state", async () => { + const rpc = makeDeferred(); + let valueAtRootSetup = -1; + + class AsyncPlugin extends Plugin { + static id = "async"; + value = 0; + setup() { + onWillStart(async () => { + this.value = await rpc; + }); + } + } + + class Root extends Component { + static template = xml``; + p = plugin(AsyncPlugin); + setup() { + valueAtRootSetup = this.p.value; + } + } + + const fixture = makeTestFixture(); + const app = new App({ plugins: [AsyncPlugin] }); + // Setup must NOT have run yet — plugins are still pending. + const mounted = app.createRoot(Root).mount(fixture); + expect(valueAtRootSetup).toBe(-1); + await nextTick(); + expect(valueAtRootSetup).toBe(-1); + + rpc.resolve(99); + await mounted; + expect(valueAtRootSetup).toBe(99); + expect(fixture.innerHTML).toBe("99"); + app.destroy(); + }); + + test("descendant of root observes loaded global async plugin state", async () => { + const rpc = makeDeferred(); + let valueAtChildSetup = -1; + + class AsyncPlugin extends Plugin { + static id = "async"; + value = 0; + setup() { + onWillStart(async () => { + this.value = await rpc; + }); + } + } + + class Child extends Component { + static template = xml``; + p = plugin(AsyncPlugin); + setup() { + valueAtChildSetup = this.p.value; + } + } + + class Root extends Component { + static template = xml``; + static components = { Child }; + } + + const fixture = makeTestFixture(); + const app = new App({ plugins: [AsyncPlugin] }); + const mounted = app.createRoot(Root).mount(fixture); + await nextTick(); + expect(valueAtChildSetup).toBe(-1); + + rpc.resolve(7); + await mounted; + expect(valueAtChildSetup).toBe(7); + expect(fixture.innerHTML).toBe("7"); + app.destroy(); + }); + + test("nested providePlugins: grandchild sees both levels loaded", async () => { + const rpc1 = makeDeferred(); + const rpc2 = makeDeferred(); + let outerAtGrandchildSetup = -1; + let innerAtGrandchildSetup = -1; + + class Outer extends Plugin { + static id = "outer"; + value = 0; + setup() { + onWillStart(async () => { + this.value = await rpc1; + }); + } + } + + class Inner extends Plugin { + static id = "inner"; + value = 0; + setup() { + onWillStart(async () => { + this.value = await rpc2; + }); + } + } + + class Grandchild extends Component { + static template = xml``; + o = plugin(Outer); + i = plugin(Inner); + setup() { + outerAtGrandchildSetup = this.o.value; + innerAtGrandchildSetup = this.i.value; + } + } + + class Child extends Component { + static template = xml``; + static components = { Grandchild }; + setup() { + providePlugins([Inner]); + } + } + + class Root extends Component { + static template = xml``; + static components = { Child }; + setup() { + providePlugins([Outer]); + } + } + + const fixture = makeTestFixture(); + const app = new App(); + const mounted = app.createRoot(Root).mount(fixture); + await nextTick(); + expect(outerAtGrandchildSetup).toBe(-1); + expect(innerAtGrandchildSetup).toBe(-1); + + rpc1.resolve(10); + rpc2.resolve(20); + await mounted; + expect(outerAtGrandchildSetup).toBe(10); + expect(innerAtGrandchildSetup).toBe(20); + expect(fixture.innerHTML).toBe("10-20"); + app.destroy(); + }); + + test("providePlugins with multiple async plugins waits for all", async () => { + const rpc1 = makeDeferred(); + const rpc2 = makeDeferred(); + let aAtChildSetup = -1; + let bAtChildSetup = -1; + + class A extends Plugin { + static id = "a"; + value = 0; + setup() { + onWillStart(async () => { + this.value = await rpc1; + }); + } + } + class B extends Plugin { + static id = "b"; + value = 0; + setup() { + onWillStart(async () => { + this.value = await rpc2; + }); + } + } + + class Child extends Component { + static template = xml``; + a = plugin(A); + b = plugin(B); + setup() { + aAtChildSetup = this.a.value; + bAtChildSetup = this.b.value; + } + } + class Parent extends Component { + static template = xml``; + static components = { Child }; + setup() { + providePlugins([A, B]); + } + } + + const fixture = makeTestFixture(); + const app = new App(); + const mounted = app.createRoot(Parent).mount(fixture); + + // Resolve only one of the two — child must NOT have been constructed yet. + rpc1.resolve(1); + await nextTick(); + expect(aAtChildSetup).toBe(-1); + expect(bAtChildSetup).toBe(-1); + + rpc2.resolve(2); + await mounted; + expect(aAtChildSetup).toBe(1); + expect(bAtChildSetup).toBe(2); + app.destroy(); + }); + + test("providePlugins with only sync plugins does not defer render", async () => { + let renderedSynchronously = false; + + class SyncPlugin extends Plugin { + static id = "sync"; + value = 42; + } + + class Parent extends Component { + static template = xml``; + declare p: PluginInstance; + setup() { + providePlugins([SyncPlugin]); + this.p = plugin(SyncPlugin); + } + } + + const fixture = makeTestFixture(); + const app = new App(); + const root = app.createRoot(Parent); + // The root mounts the same tick — no plugin manager wait, no extra + // microtasks. We assert mount() returned a resolved promise content by + // checking the DOM is populated after a single await. + const mounted = root.mount(fixture); + mounted.then(() => (renderedSynchronously = true)); + await nextTick(); + expect(renderedSynchronously).toBe(true); + expect(fixture.innerHTML).toBe("42"); + app.destroy(); + }); + + test("providePlugins plugin rejection rejects mount", async () => { + class Broken extends Plugin { + setup() { + onWillStart(async () => { + throw new Error("kaboom"); + }); + } + } + class Parent extends Component { + static template = xml``; + setup() { + providePlugins([Broken]); + } + } + + const fixture = makeTestFixture(); + const app = new App(); + await expect(app.createRoot(Parent).mount(fixture)).rejects.toMatchObject({ + message: "kaboom", + }); + app.destroy(); + }); + test("providePlugins defers owning component render", async () => { const rpc = makeDeferred(); let valueAtChildSetup = -1;