Skip to content

Commit 76c33ad

Browse files
committed
add suspense component
1 parent 2e903bf commit 76c33ad

File tree

4 files changed

+781
-0
lines changed

4 files changed

+781
-0
lines changed

src/runtime/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export { Plugin } from "./plugin_manager";
6868
export type { PluginConstructor } from "./plugin_manager";
6969
export { useContext } from "./context";
7070
export type { CapturedContext } from "./context";
71+
export { Suspense } from "./suspense";
7172

7273
export const __info__ = {
7374
version: App.version,

src/runtime/suspense.ts

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
import { BDom, VNode } from "./blockdom";
2+
import { Component } from "./component";
3+
import { ComponentNode } from "./component_node";
4+
import { Fiber, RootFiber } from "./rendering/fibers";
5+
import { STATUS } from "./status";
6+
7+
// ---------------------------------------------------------------------------
8+
// SuspenseBoundaryRoot
9+
// ---------------------------------------------------------------------------
10+
11+
/**
12+
* A special RootFiber that acts as a boundary for Suspense content children.
13+
* Instead of patching the DOM directly (like a normal RootFiber), its
14+
* complete() method triggers the VSuspense to swap fallback → content.
15+
*/
16+
class SuspenseBoundaryRoot extends RootFiber {
17+
vsuspense: VSuspense | null = null;
18+
19+
constructor(node: ComponentNode) {
20+
super(node, null);
21+
// Override: counter starts at 0 because the boundary root itself never
22+
// renders. It only tracks content children's async work.
23+
this.counter = 0;
24+
}
25+
26+
complete() {
27+
if (this.appliedToDom) {
28+
return; // Prevent double completion
29+
}
30+
const vs = this.vsuspense;
31+
if (!vs) {
32+
return;
33+
}
34+
this.locked = true;
35+
let current: Fiber | undefined = undefined;
36+
let mountedFibers = this.mounted;
37+
try {
38+
// Step 1: call willPatch lifecycle hooks
39+
for (current of this.willPatch) {
40+
let node = current.node;
41+
if (node.fiber === current) {
42+
const component = node.component;
43+
for (let cb of node.willPatch) {
44+
cb.call(component);
45+
}
46+
}
47+
}
48+
current = undefined;
49+
50+
// Step 2: apply content to DOM (mount or patch)
51+
vs.applyContent();
52+
53+
this.locked = false;
54+
this.appliedToDom = true;
55+
56+
// Step 3: call mounted lifecycle hooks
57+
while ((current = mountedFibers.pop())) {
58+
if (current.appliedToDom) {
59+
for (let cb of current.node.mounted) {
60+
cb();
61+
}
62+
}
63+
}
64+
65+
// Step 4: call patched lifecycle hooks
66+
while ((current = this.patched.pop())) {
67+
if (current.appliedToDom) {
68+
for (let cb of current.node.patched) {
69+
cb();
70+
}
71+
}
72+
}
73+
} catch (e) {
74+
for (let fiber of mountedFibers) {
75+
fiber.node.willUnmount = [];
76+
}
77+
this.locked = false;
78+
this.node.app.handleError({ fiber: current || this, error: e });
79+
}
80+
}
81+
}
82+
83+
// ---------------------------------------------------------------------------
84+
// VSuspense VNode
85+
// ---------------------------------------------------------------------------
86+
87+
const nodeInsertBefore = Node.prototype.insertBefore;
88+
const nodeRemoveChild = Node.prototype.removeChild;
89+
90+
/**
91+
* A blockdom VNode that manages two sub-trees: content and fallback.
92+
* It handles the transition between fallback and content based on the
93+
* SuspenseBoundaryRoot's completion.
94+
*/
95+
class VSuspense implements VNode<VSuspense> {
96+
content: BDom;
97+
fallback: BDom;
98+
boundaryRoot: SuspenseBoundaryRoot;
99+
delay: number;
100+
101+
parentEl?: HTMLElement;
102+
anchor?: Text; // text node anchor for positioning
103+
showingContent: boolean = false;
104+
showingFallback: boolean = false;
105+
delayTimer: ReturnType<typeof setTimeout> | null = null;
106+
pendingContent: BDom | null = null;
107+
pendingBoundaryRoot: SuspenseBoundaryRoot | null = null;
108+
109+
constructor(
110+
content: BDom,
111+
fallback: BDom,
112+
boundaryRoot: SuspenseBoundaryRoot,
113+
delay: number
114+
) {
115+
this.content = content;
116+
this.fallback = fallback;
117+
this.boundaryRoot = boundaryRoot;
118+
this.delay = delay;
119+
}
120+
121+
mount(parent: HTMLElement, afterNode: Node | null) {
122+
this.parentEl = parent;
123+
this.anchor = document.createTextNode("");
124+
nodeInsertBefore.call(parent, this.anchor, afterNode);
125+
126+
if (this.boundaryRoot.counter === 0) {
127+
// Content is already ready (sync content or already resolved).
128+
// Mount content directly — no fallback needed.
129+
this.content.mount(parent, this.anchor);
130+
this.showingContent = true;
131+
} else if (this.delay === 0) {
132+
// No delay: show fallback immediately
133+
this.fallback.mount(parent, this.anchor);
134+
this.showingFallback = true;
135+
} else {
136+
// Delay > 0: wait before showing fallback
137+
this.delayTimer = setTimeout(() => {
138+
this.delayTimer = null;
139+
if (!this.showingContent) {
140+
this.fallback.mount(this.parentEl!, this.anchor!);
141+
this.showingFallback = true;
142+
}
143+
}, this.delay);
144+
}
145+
}
146+
147+
/**
148+
* Called by SuspenseBoundaryRoot.complete() when content children are ready.
149+
*/
150+
applyContent() {
151+
if (this.showingContent) {
152+
// Update case: patch old content with new
153+
if (this.pendingContent) {
154+
this.content.patch(this.pendingContent, true);
155+
this.content = this.pendingContent;
156+
this.pendingContent = null;
157+
this.pendingBoundaryRoot = null;
158+
}
159+
} else {
160+
// Initial mount: swap fallback/placeholder → content
161+
if (this.delayTimer !== null) {
162+
clearTimeout(this.delayTimer);
163+
this.delayTimer = null;
164+
}
165+
if (this.showingFallback) {
166+
this.fallback.beforeRemove();
167+
this.fallback.remove();
168+
this.showingFallback = false;
169+
}
170+
this.content.mount(this.parentEl!, this.anchor!);
171+
this.showingContent = true;
172+
}
173+
}
174+
175+
moveBeforeDOMNode(node: Node | null, parent?: HTMLElement) {
176+
if (this.showingContent) {
177+
this.content.moveBeforeDOMNode(node, parent);
178+
} else if (this.showingFallback) {
179+
this.fallback.moveBeforeDOMNode(node, parent);
180+
}
181+
// Also move the anchor
182+
const targetParent = parent || this.parentEl!;
183+
nodeInsertBefore.call(targetParent, this.anchor!, node);
184+
}
185+
186+
moveBeforeVNode(other: VSuspense | null, afterNode: Node | null) {
187+
this.moveBeforeDOMNode((other && other.firstNode()) || afterNode);
188+
}
189+
190+
patch(other: VSuspense, withBeforeRemove: boolean) {
191+
if (this === other) {
192+
return;
193+
}
194+
195+
if (this.showingContent) {
196+
// Content is currently visible
197+
if (other.boundaryRoot.counter === 0) {
198+
// New content is ready immediately: patch content directly
199+
this.content.patch(other.content, withBeforeRemove);
200+
this.content = other.content;
201+
this.boundaryRoot = other.boundaryRoot;
202+
this.boundaryRoot.vsuspense = this;
203+
this.boundaryRoot.appliedToDom = true;
204+
} else {
205+
// New content has async work: keep showing old content
206+
this.pendingContent = other.content;
207+
this.pendingBoundaryRoot = other.boundaryRoot;
208+
this.boundaryRoot = other.boundaryRoot;
209+
this.boundaryRoot.vsuspense = this;
210+
}
211+
} else {
212+
// Fallback is visible (or placeholder with delay timer)
213+
// Patch fallback
214+
if (this.showingFallback) {
215+
this.fallback.patch(other.fallback, withBeforeRemove);
216+
this.fallback = other.fallback;
217+
} else {
218+
this.fallback = other.fallback;
219+
}
220+
// Update content reference
221+
this.content = other.content;
222+
this.boundaryRoot = other.boundaryRoot;
223+
this.boundaryRoot.vsuspense = this;
224+
this.delay = other.delay;
225+
}
226+
}
227+
228+
beforeRemove() {
229+
if (this.showingContent) {
230+
this.content.beforeRemove();
231+
} else if (this.showingFallback) {
232+
this.fallback.beforeRemove();
233+
}
234+
}
235+
236+
remove() {
237+
if (this.delayTimer !== null) {
238+
clearTimeout(this.delayTimer);
239+
this.delayTimer = null;
240+
}
241+
if (this.showingContent) {
242+
this.content.remove();
243+
} else if (this.showingFallback) {
244+
this.fallback.remove();
245+
}
246+
nodeRemoveChild.call(this.parentEl!, this.anchor!);
247+
}
248+
249+
firstNode(): Node | undefined {
250+
if (this.showingContent) {
251+
return this.content.firstNode();
252+
}
253+
if (this.showingFallback) {
254+
return this.fallback.firstNode();
255+
}
256+
return this.anchor;
257+
}
258+
}
259+
260+
// ---------------------------------------------------------------------------
261+
// Suspense template function
262+
// ---------------------------------------------------------------------------
263+
264+
export function suspenseTemplate(app: any, bdom: any, helpers: any) {
265+
let { callSlot } = helpers;
266+
267+
return function template(ctx: any, node: any, key = ""): any {
268+
const props = ctx.__owl__.props;
269+
const delay = props.delay || 0;
270+
271+
// ---- Clean up old boundary root if re-rendering ----
272+
const oldBoundary: SuspenseBoundaryRoot | undefined = (node as any)._suspenseBoundary;
273+
if (oldBoundary) {
274+
// Cancel unmounted content children (STATUS.NEW) — they'll be recreated
275+
// with fresh willStart. Mounted children are kept and updated normally.
276+
for (let child of oldBoundary.children) {
277+
if (child.node.status === STATUS.NEW) {
278+
child.node.cancel();
279+
child.node.fiber = null;
280+
}
281+
}
282+
node.app.scheduler.tasks.delete(oldBoundary);
283+
}
284+
285+
// ---- Create new boundary root ----
286+
const boundaryRoot = new SuspenseBoundaryRoot(node);
287+
(node as any)._suspenseBoundary = boundaryRoot;
288+
node.app.scheduler.addFiber(boundaryRoot);
289+
290+
// ---- Swap fiber: content children's fibers go to boundary root ----
291+
const realFiber = node.fiber;
292+
node.fiber = boundaryRoot;
293+
294+
// Render content slot (child components created here use boundaryRoot as parent)
295+
const contentBdom = callSlot(ctx, node, key, "default", false, null);
296+
297+
// Copy content children to real fiber's childrenMap so they persist in
298+
// node.children across re-renders (createComponent looks them up there)
299+
for (let k in boundaryRoot.childrenMap) {
300+
realFiber.childrenMap[k] = boundaryRoot.childrenMap[k];
301+
}
302+
303+
// ---- Restore fiber ----
304+
node.fiber = realFiber;
305+
306+
// Render fallback slot (uses the real fiber, so fallback is part of the
307+
// normal rendering flow)
308+
const fallbackBdom = callSlot(ctx, node, key, "fallback", false, null);
309+
310+
// ---- Create VSuspense ----
311+
const vs = new VSuspense(contentBdom, fallbackBdom, boundaryRoot, delay);
312+
boundaryRoot.vsuspense = vs;
313+
return vs;
314+
};
315+
}
316+
317+
// ---------------------------------------------------------------------------
318+
// Suspense Component
319+
// ---------------------------------------------------------------------------
320+
321+
export class Suspense extends Component {
322+
static template = "__suspense__";
323+
}

src/runtime/template_set.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { comment, createBlock, html, list, multi, text, toggler } from "./blockd
55
import { getContext } from "./context";
66
import { Portal, portalTemplate } from "./portal";
77
import { helpers } from "./rendering/template_helpers";
8+
import { suspenseTemplate } from "./suspense";
89

910
const bdom = { text, createBlock, list, multi, html, toggler, comment };
1011

@@ -136,3 +137,4 @@ export function xml(...args: Parameters<typeof String.raw>) {
136137
xml.nextId = 1;
137138

138139
TemplateSet.registerTemplate("__portal__", portalTemplate);
140+
TemplateSet.registerTemplate("__suspense__", suspenseTemplate);

0 commit comments

Comments
 (0)