Skip to content

Commit f40afac

Browse files
committed
feat: Initial attempt at wrapping Runtime in a worker within shell.
1 parent f472e6f commit f40afac

File tree

70 files changed

+4339
-1190
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

70 files changed

+4339
-1190
lines changed

packages/charm/src/ops/charm-controller.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
interface CharmCellIo {
2424
get(path?: CellPath): unknown;
2525
set(value: unknown, path?: CellPath): Promise<void>;
26+
getCell(): Cell<unknown>;
2627
}
2728

2829
type CharmPropIoType = "result" | "input";
@@ -40,6 +41,10 @@ class CharmPropIo implements CharmCellIo {
4041
return resolveCellPath(targetCell, path ?? []);
4142
}
4243

44+
getCell(): Cell<unknown> {
45+
return this.#getTargetCell();
46+
}
47+
4348
async set(value: unknown, path?: CellPath) {
4449
const manager = this.#cc.manager();
4550

packages/cli/lib/charm-render.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { render, vdomSchema, VNode } from "@commontools/html";
2-
import { Cell, UI } from "@commontools/runner";
2+
import { UI } from "@commontools/runner";
3+
import { type RemoteCell } from "@commontools/runner/worker";
34
import { loadManager } from "./charm.ts";
45
import { CharmsController } from "@commontools/charm/ops";
56
import type { CharmConfig } from "./charm.ts";
@@ -53,7 +54,11 @@ export async function renderCharm(
5354
if (options.watch) {
5455
// 4a. Reactive rendering - pass the Cell directly
5556
const uiCell = cell.key(UI);
56-
const cancel = render(container, uiCell as Cell<VNode>, renderOptions); // FIXME: types
57+
const cancel = render(
58+
container,
59+
uiCell as unknown as RemoteCell<VNode>,
60+
renderOptions,
61+
); // FIXME: types
5762

5863
// 5a. Set up monitoring for changes
5964
let updateCount = 0;

packages/html/deno.jsonc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
".": "./src/index.ts",
88
"./utils": "./src/utils.ts",
99
"./jsx-runtime": "./src/jsx-runtime.ts",
10-
"./jsx-dev-runtime": "./src/jsx-dev-runtime.ts"
10+
"./jsx-dev-runtime": "./src/jsx-dev-runtime.ts",
11+
"./mock-doc": "./src/mock-doc.ts"
1112
},
1213
"imports": {
1314
"htmlparser2": "npm:htmlparser2",

packages/html/src/mock-doc.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ export class MockDoc {
8080
return DomUtils.removeElement(this as any);
8181
},
8282
},
83+
replaceWith: {
84+
value(newNode: any) {
85+
return DomUtils.replaceElement(this as any, newNode);
86+
},
87+
},
8388
innerHTML: {
8489
get() {
8590
return domserializer.render((this as any).children);

packages/html/src/render.ts

Lines changed: 129 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import { isObject, isRecord } from "@commontools/utils/types";
22
import {
33
type Cancel,
4-
type Cell,
54
convertCellsToLinks,
65
effect,
7-
isCell,
86
type JSONSchema,
97
UI,
108
useCancelGroup,
119
} from "@commontools/runner";
10+
import { isRemoteCell, type RemoteCell } from "@commontools/runner/worker";
1211
import { isVNode, type Props, type RenderNode, type VNode } from "./jsx.ts";
1312
import * as logger from "./logger.ts";
1413

@@ -22,7 +21,7 @@ export interface RenderOptions {
2221
setProp?: SetPropHandler;
2322
document?: Document;
2423
/** The root cell for auto-wrapping with ct-cell-context on [UI] traversal */
25-
rootCell?: Cell;
24+
rootCell?: RemoteCell<VNode>;
2625
}
2726

2827
export const vdomSchema: JSONSchema = {
@@ -60,25 +59,32 @@ export const vdomSchema: JSONSchema = {
6059
*/
6160
export const render = (
6261
parent: HTMLElement,
63-
view: VNode | Cell<VNode>,
62+
view: VNode | RemoteCell<VNode>,
6463
options: RenderOptions = {},
6564
): Cancel => {
66-
// Initialize visited set with the original cell for cycle detection
67-
const visited = new Set<object>();
68-
let rootCell: Cell | undefined;
69-
70-
if (isCell(view)) {
71-
visited.add(view);
72-
rootCell = view; // Capture the original cell for ct-cell-context wrapping
73-
view = view.asSchema(vdomSchema);
65+
let rootCell: RemoteCell<VNode> | undefined;
66+
67+
if (isRemoteCell(view)) {
68+
rootCell = view as RemoteCell<VNode>; // Capture the original cell for ct-cell-context wrapping
69+
// Don't apply vdomSchema to RemoteCell - it causes the worker to return
70+
// cell references (SigilLinks) instead of actual values, which creates
71+
// infinite chains of RemoteCells that need resolution.
7472
}
7573

7674
// Pass rootCell through options if we have one
7775
const optionsWithCell = rootCell ? { ...options, rootCell } : options;
7876

7977
return effect(
80-
view,
81-
(view: VNode) => renderImpl(parent, view, optionsWithCell, visited),
78+
view as VNode,
79+
(view: VNode) => {
80+
// Create a fresh visited set for each render pass.
81+
// This prevents false cycle detection when re-rendering with updated values.
82+
const visited = new Set<object>();
83+
if (rootCell) {
84+
visited.add(rootCell);
85+
}
86+
return renderImpl(parent, view, optionsWithCell, visited);
87+
},
8288
);
8389
};
8490

@@ -131,7 +137,7 @@ const createCyclePlaceholder = (document: Document): HTMLSpanElement => {
131137
/** Check if a cell has been visited, using .equals() for cell comparison */
132138
const hasVisitedCell = (
133139
visited: Set<object>,
134-
cell: Cell<unknown>,
140+
cell: { equals(other: unknown): boolean },
135141
): boolean => {
136142
for (const item of visited) {
137143
if (cell.equals(item)) {
@@ -207,7 +213,7 @@ const renderNode = (
207213
if (cellForContext && element) {
208214
const wrapper = document.createElement(
209215
"ct-cell-context",
210-
) as HTMLElement & { cell?: Cell };
216+
) as HTMLElement & { cell?: RemoteCell<VNode> };
211217
wrapper.cell = cellForContext;
212218
wrapper.appendChild(element);
213219
return [wrapper, cancel];
@@ -216,6 +222,35 @@ const renderNode = (
216222
return [element, cancel];
217223
};
218224

225+
/**
226+
* Recursively resolve RemoteCells to get actual values.
227+
* This is needed when rendering with RuntimeWorker, where cell values
228+
* may contain SigilLinks that get rehydrated to RemoteCells.
229+
*/
230+
const resolveRemoteCells = async (val: unknown): Promise<unknown> => {
231+
if (isRemoteCell(val)) {
232+
const cell = val as RemoteCell<unknown>;
233+
await cell.sync();
234+
const resolved = cell.get();
235+
return resolveRemoteCells(resolved);
236+
}
237+
if (Array.isArray(val)) {
238+
return Promise.all(val.map((item) => resolveRemoteCells(item)));
239+
}
240+
return val;
241+
};
242+
243+
/**
244+
* Check if a value contains RemoteCells that need resolution.
245+
*/
246+
const needsRemoteCellResolution = (val: unknown): boolean => {
247+
if (isRemoteCell(val)) return true;
248+
if (Array.isArray(val)) {
249+
return val.some((item) => needsRemoteCellResolution(item));
250+
}
251+
return false;
252+
};
253+
219254
const bindChildren = (
220255
element: HTMLElement,
221256
children: RenderNode,
@@ -235,25 +270,50 @@ const bindChildren = (
235270
const document = options.document ?? globalThis.document;
236271

237272
// Check for cell cycle before setting up effect (using .equals() for comparison)
238-
if (isCell(child) && hasVisitedCell(visited, child)) {
273+
if (
274+
isRenderableCell(child) &&
275+
hasVisitedCell(visited, child as unknown as RemoteCell<unknown>)
276+
) {
239277
logger.warn("render", "Cycle detected in cell graph", child);
240278
return { node: createCyclePlaceholder(document), cancel: () => {} };
241279
}
242280

243281
// Track if this child is a cell for the visited set
244-
const childIsCell = isCell(child);
282+
const childIsCell = isRenderableCell(child);
245283

246284
let currentNode: ChildNode | null = null;
285+
247286
const cancel = effect(child, (childValue) => {
287+
// If the value contains RemoteCells (from worker rehydration), resolve them first
288+
if (needsRemoteCellResolution(childValue)) {
289+
resolveRemoteCells(childValue).then((value) => {
290+
// If resolved to an array, render first item (shouldn't happen for single child)
291+
if (Array.isArray(value)) {
292+
if (value.length > 0) {
293+
renderValue(value[0] as RenderNode);
294+
}
295+
} else {
296+
renderValue(value as RenderNode);
297+
}
298+
}).catch((e) => {
299+
logger.warn("render", "Failed to resolve RemoteCell", e);
300+
});
301+
// Render empty placeholder while waiting for resolution
302+
return renderValue(undefined as unknown as RenderNode);
303+
}
304+
return renderValue(childValue as RenderNode);
305+
});
306+
307+
function renderValue(value: RenderNode): Cancel | undefined {
248308
let newRendered: { node: ChildNode; cancel: Cancel };
249-
if (isVNode(childValue)) {
309+
if (isVNode(value)) {
250310
// Create visited set for this child's subtree (cloned to avoid sibling interference)
251311
const childVisited = new Set(visited);
252312
if (childIsCell) {
253313
childVisited.add(child);
254314
}
255315
const [childElement, childCancel] = renderNode(
256-
childValue,
316+
value,
257317
options,
258318
childVisited,
259319
);
@@ -262,17 +322,25 @@ const bindChildren = (
262322
cancel: childCancel ?? (() => {}),
263323
};
264324
} else {
325+
let textValue: string | number | boolean = value as
326+
| string
327+
| number
328+
| boolean;
265329
if (
266-
childValue === null || childValue === undefined ||
267-
childValue === false
330+
textValue === null || textValue === undefined ||
331+
textValue === false
268332
) {
269-
childValue = "";
270-
} else if (typeof childValue === "object") {
271-
console.warn("unexpected object when value was expected", childValue);
272-
childValue = JSON.stringify(childValue);
333+
textValue = "";
334+
} else if (typeof textValue === "object") {
335+
logger.warn(
336+
"render",
337+
"unexpected object when value was expected",
338+
textValue,
339+
);
340+
textValue = JSON.stringify(textValue);
273341
}
274342
newRendered = {
275-
node: document.createTextNode(childValue.toString()),
343+
node: document.createTextNode(textValue.toString()),
276344
cancel: () => {},
277345
};
278346
}
@@ -289,7 +357,7 @@ const bindChildren = (
289357

290358
currentNode = newRendered.node;
291359
return newRendered.cancel;
292-
});
360+
}
293361

294362
return { node: currentNode!, cancel };
295363
};
@@ -340,10 +408,10 @@ const bindChildren = (
340408
for (let i = 0; i < newKeyOrder.length; i++) {
341409
const key = newKeyOrder[i];
342410
const desiredNode = newMapping.get(key)!.node;
343-
// If there's no node at this position, or its different, insert desiredNode there.
411+
// If there's no node at this position, or it's different, insert desiredNode there.
344412
if (domNodes[i] !== desiredNode) {
345413
// Using domNodes[i] (which may be undefined) is equivalent to appending
346-
// if theres no node at that index.
414+
// if there's no node at that index.
347415
element.insertBefore(desiredNode, domNodes[i] ?? null);
348416
}
349417
}
@@ -356,7 +424,25 @@ const bindChildren = (
356424
// Set up a reactive effect so that changes to the children array are diffed and applied.
357425
const cancelArrayEffect = effect(
358426
children,
359-
(childrenVal) => updateChildren(childrenVal),
427+
(childrenVal) => {
428+
// If the value contains RemoteCells (from worker rehydration), resolve them first
429+
if (needsRemoteCellResolution(childrenVal)) {
430+
resolveRemoteCells(childrenVal).then((resolved) => {
431+
updateChildren(
432+
resolved as RenderNode | RenderNode[] | undefined | null,
433+
);
434+
}).catch((e) => {
435+
logger.warn("render", "Failed to resolve RemoteCell children", e);
436+
});
437+
// Render empty while waiting for resolution
438+
updateChildren(undefined);
439+
return undefined;
440+
}
441+
updateChildren(
442+
childrenVal as RenderNode | RenderNode[] | undefined | null,
443+
);
444+
return undefined;
445+
},
360446
);
361447

362448
// Return a cancel function that tears down the effect and cleans up any rendered nodes.
@@ -377,7 +463,7 @@ const bindProps = (
377463
const setProperty = options.setProp ?? setProp;
378464
const [cancel, addCancel] = useCancelGroup();
379465
for (const [propKey, propValue] of Object.entries(props)) {
380-
if (isCell(propValue)) {
466+
if (isRenderableCell(propValue)) {
381467
// If prop is an event, we need to add an event listener
382468
if (isEventProp(propKey)) {
383469
const key = cleanEventProp(propKey);
@@ -538,10 +624,10 @@ const sanitizeScripts = (node: VNode): VNode | null => {
538624
if (node.name === "script") {
539625
return null;
540626
}
541-
if (!isCell(node.props) && !isObject(node.props)) {
627+
if (!isRenderableCell(node.props) && !isObject(node.props)) {
542628
node = { ...node, props: {} };
543629
}
544-
if (!isCell(node.children) && !Array.isArray(node.children)) {
630+
if (!isRenderableCell(node.children) && !Array.isArray(node.children)) {
545631
node = { ...node, children: [] };
546632
}
547633

@@ -654,3 +740,12 @@ function isSelectElement(value: unknown): value is HTMLSelectElement {
654740
typeof value.tagName === "string" &&
655741
value.tagName.toUpperCase() === "SELECT");
656742
}
743+
744+
type RenderableCell = {
745+
send(value: unknown): void;
746+
};
747+
function isRenderableCell(value: unknown): value is RenderableCell {
748+
// Check for any object with a send() method (Cell, RemoteCell, or Stream)
749+
return !!value && typeof value === "object" && "send" in value &&
750+
typeof (value as RenderableCell).send === "function";
751+
}

packages/html/test/html-recipes.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
type IExtendedStorageTransaction,
88
Runtime,
99
} from "@commontools/runner";
10+
import { type RemoteCell } from "@commontools/runner/worker";
1011
import { StorageManager } from "@commontools/runner/storage/cache.deno";
1112
import * as assert from "./assert.ts";
1213
import { Identity } from "@commontools/identity";
@@ -167,7 +168,7 @@ describe("recipes with HTML", () => {
167168

168169
const root = document.getElementById("root")!;
169170
const cell = result.key(UI);
170-
render(root, cell, renderOptions);
171+
render(root, cell as unknown as RemoteCell<VNode>, renderOptions);
171172
assert.equal(root.innerHTML, "<div><div>test</div></div>");
172173
});
173174

@@ -277,7 +278,11 @@ describe("recipes with HTML", () => {
277278
await runtime.idle();
278279

279280
const root = document.getElementById("root")!;
280-
render(root, cell1.key("ui"), renderOptions);
281+
render(
282+
root,
283+
cell1.key("ui") as unknown as RemoteCell<VNode>,
284+
renderOptions,
285+
);
281286

282287
// Should detect the cycle and render placeholder, not infinite loop
283288
// MockDoc doesn't properly reflect textContent/title in innerHTML,

0 commit comments

Comments
 (0)