Skip to content
Open

wip #1885

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
144 changes: 108 additions & 36 deletions packages/owl-runtime/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ declare global {
import type { MountTarget } from "./blockdom";

interface Root<T extends ComponentConstructor> {
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<ComponentInstance<T>>;
// Kick off rendering without a DOM target. Descendants' onWillStart fires
// immediately and the bdom is built in memory. Idempotent — second call
Expand Down Expand Up @@ -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<void> | null = null;

const prepare = (): Promise<void> => {
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<void> => {
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();
Expand All @@ -149,28 +164,22 @@ export class App extends TemplateSet {
const ready = new Promise<void>((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 {
Expand All @@ -179,6 +188,36 @@ export class App extends TemplateSet {
reject(e);
}
}
return ready;
};

const prepare = (): Promise<void> => {
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;
};

Expand All @@ -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<T> = {
get node() {
return node;
},
promise,
prepare,
mount,
destroy: () => {
cancelled = true;
this.roots.delete(root);
node?.destroy();
this.scheduler.processTasks();
Expand Down
10 changes: 8 additions & 2 deletions packages/owl-runtime/src/portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
12 changes: 9 additions & 3 deletions packages/owl-runtime/src/suspense.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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\`);
}
}"
`;
Expand All @@ -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);
}
}"
`;
Expand Down
6 changes: 5 additions & 1 deletion packages/owl-runtime/tests/components/plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Loading
Loading