Skip to content
Draft
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
2 changes: 2 additions & 0 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"./packages/memory",
"./packages/patterns",
"./packages/runner",
"./packages/runtime-client",
"./packages/seeder",
"./packages/shell",
"./packages/static",
Expand Down Expand Up @@ -64,6 +65,7 @@
"exclude": [
".beads/",
"./packages/static/assets",
"./packages/ui/src/v2/components/ct-outliner",
"./packages/vendor-astral"
],
"rules": {
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/lib/charm-render.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { render, vdomSchema, VNode } from "@commontools/html";
import { Cell, UI } from "@commontools/runner";
import { UI } from "@commontools/runner";
import { type RemoteCell } from "@commontools/runtime-client";
import { loadManager } from "./charm.ts";
import { CharmsController } from "@commontools/charm/ops";
import type { CharmConfig } from "./charm.ts";
Expand Down Expand Up @@ -53,7 +54,11 @@ export async function renderCharm(
if (options.watch) {
// 4a. Reactive rendering - pass the Cell directly
const uiCell = cell.key(UI);
const cancel = render(container, uiCell as Cell<VNode>, renderOptions); // FIXME: types
const cancel = render(
container,
uiCell as unknown as RemoteCell<VNode>,
renderOptions,
); // FIXME: types

// 5a. Set up monitoring for changes
let updateCount = 0;
Expand Down
5 changes: 2 additions & 3 deletions packages/html/src/jsx.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { isObject } from "@commontools/utils/types";
import { UI, type VNode } from "@commontools/runner";
export type { Props, RenderNode } from "@commontools/runner";
export { type VNode };
import { Props, RenderNode, UI, VNode } from "@commontools/runtime-client";
export { type Props, type RenderNode, UI, type VNode };

/**
* Type guard to check if a value is a VNode.
Expand Down
159 changes: 123 additions & 36 deletions packages/html/src/render.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { isObject, isRecord } from "@commontools/utils/types";
import { convertCellsToLinks } from "@commontools/runner";
import {
type Cancel,
type Cell,
convertCellsToLinks,
effect,
isCell,
isRemoteCell,
type JSONSchema,
type RemoteCell,
UI,
useCancelGroup,
} from "@commontools/runner";
} from "@commontools/runtime-client";
import { isVNode, type Props, type RenderNode, type VNode } from "./jsx.ts";

export type SetPropHandler = <T>(
Expand All @@ -21,7 +21,7 @@ export interface RenderOptions {
setProp?: SetPropHandler;
document?: Document;
/** The root cell for auto-wrapping with ct-cell-context on [UI] traversal */
rootCell?: Cell;
rootCell?: RemoteCell<VNode>;
}

export const vdomSchema: JSONSchema = {
Expand Down Expand Up @@ -59,25 +59,32 @@ export const vdomSchema: JSONSchema = {
*/
export const render = (
parent: HTMLElement,
view: VNode | Cell<VNode>,
view: VNode | RemoteCell<VNode>,
options: RenderOptions = {},
): Cancel => {
// Initialize visited set with the original cell for cycle detection
const visited = new Set<object>();
let rootCell: Cell | undefined;

if (isCell(view)) {
visited.add(view);
rootCell = view; // Capture the original cell for ct-cell-context wrapping
view = view.asSchema(vdomSchema);
let rootCell: RemoteCell<VNode> | undefined;

if (isRemoteCell(view)) {
rootCell = view as RemoteCell<VNode>; // Capture the original cell for ct-cell-context wrapping
// Don't apply vdomSchema to RemoteCell - it causes the worker to return
// cell references (SigilLinks) instead of actual values, which creates
// infinite chains of RemoteCells that need resolution.
}

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

return effect(
view,
(view: VNode) => renderImpl(parent, view, optionsWithCell, visited),
view as VNode,
(view: VNode) => {
// Create a fresh visited set for each render pass.
// This prevents false cycle detection when re-rendering with updated values.
const visited = new Set<object>();
if (rootCell) {
visited.add(rootCell);
}
return renderImpl(parent, view, optionsWithCell, visited);
},
);
};

Expand Down Expand Up @@ -127,7 +134,7 @@ const createCyclePlaceholder = (document: Document): HTMLSpanElement => {
/** Check if a cell has been visited, using .equals() for cell comparison */
const hasVisitedCell = (
visited: Set<object>,
cell: Cell<unknown>,
cell: { equals(other: unknown): boolean },
): boolean => {
for (const item of visited) {
if (cell.equals(item)) {
Expand Down Expand Up @@ -201,7 +208,7 @@ const renderNode = (
if (cellForContext && element) {
const wrapper = document.createElement(
"ct-cell-context",
) as HTMLElement & { cell?: Cell };
) as HTMLElement & { cell?: RemoteCell<VNode> };
wrapper.cell = cellForContext;
wrapper.appendChild(element);
return [wrapper, cancel];
Expand All @@ -210,6 +217,35 @@ const renderNode = (
return [element, cancel];
};

/**
* Recursively resolve RemoteCells to get actual values.
* This is needed when rendering with RuntimeWorker, where cell values
* may contain SigilLinks that get rehydrated to RemoteCells.
*/
const resolveRemoteCells = async (val: unknown): Promise<unknown> => {
if (isRemoteCell(val)) {
const cell = val as RemoteCell<unknown>;
await cell.sync();
const resolved = cell.get();
return resolveRemoteCells(resolved);
}
if (Array.isArray(val)) {
return Promise.all(val.map((item) => resolveRemoteCells(item)));
}
return val;
};

/**
* Check if a value contains RemoteCells that need resolution.
*/
const needsRemoteCellResolution = (val: unknown): boolean => {
if (isRemoteCell(val)) return true;
if (Array.isArray(val)) {
return val.some((item) => needsRemoteCellResolution(item));
}
return false;
};

const bindChildren = (
element: HTMLElement,
children: RenderNode,
Expand All @@ -229,24 +265,47 @@ const bindChildren = (
const document = options.document ?? globalThis.document;

// Check for cell cycle before setting up effect (using .equals() for comparison)
if (isCell(child) && hasVisitedCell(visited, child)) {
if (
isRenderableCell(child) &&
hasVisitedCell(visited, child as unknown as RemoteCell<unknown>)
) {
return { node: createCyclePlaceholder(document), cancel: () => {} };
}

// Track if this child is a cell for the visited set
const childIsCell = isCell(child);
const childIsCell = isRenderableCell(child);

let currentNode: ChildNode | null = null;

const cancel = effect(child, (childValue) => {
// If the value contains RemoteCells (from worker rehydration), resolve them first
if (needsRemoteCellResolution(childValue)) {
resolveRemoteCells(childValue).then((value) => {
// If resolved to an array, render first item (shouldn't happen for single child)
if (Array.isArray(value)) {
if (value.length > 0) {
renderValue(value[0] as RenderNode);
}
} else {
renderValue(value as RenderNode);
}
});
// Render empty placeholder while waiting for resolution
return renderValue(undefined as unknown as RenderNode);
}
return renderValue(childValue as RenderNode);
});

function renderValue(value: RenderNode): Cancel | undefined {
let newRendered: { node: ChildNode; cancel: Cancel };
if (isVNode(childValue)) {
if (isVNode(value)) {
// Create visited set for this child's subtree (cloned to avoid sibling interference)
const childVisited = new Set(visited);
if (childIsCell) {
childVisited.add(child);
}
const [childElement, childCancel] = renderNode(
childValue,
value,
options,
childVisited,
);
Expand All @@ -255,17 +314,20 @@ const bindChildren = (
cancel: childCancel ?? (() => {}),
};
} else {
let textValue: string | number | boolean = value as
| string
| number
| boolean;
if (
childValue === null || childValue === undefined ||
childValue === false
textValue === null || textValue === undefined ||
textValue === false
) {
childValue = "";
} else if (typeof childValue === "object") {
console.warn("unexpected object when value was expected", childValue);
childValue = JSON.stringify(childValue);
textValue = "";
} else if (typeof textValue === "object") {
textValue = JSON.stringify(textValue);
}
newRendered = {
node: document.createTextNode(childValue.toString()),
node: document.createTextNode(textValue.toString()),
cancel: () => {},
};
}
Expand All @@ -282,7 +344,7 @@ const bindChildren = (

currentNode = newRendered.node;
return newRendered.cancel;
});
}

return { node: currentNode!, cancel };
};
Expand Down Expand Up @@ -333,10 +395,10 @@ const bindChildren = (
for (let i = 0; i < newKeyOrder.length; i++) {
const key = newKeyOrder[i];
const desiredNode = newMapping.get(key)!.node;
// If there's no node at this position, or its different, insert desiredNode there.
// If there's no node at this position, or it's different, insert desiredNode there.
if (domNodes[i] !== desiredNode) {
// Using domNodes[i] (which may be undefined) is equivalent to appending
// if theres no node at that index.
// if there's no node at that index.
element.insertBefore(desiredNode, domNodes[i] ?? null);
}
}
Expand All @@ -347,7 +409,23 @@ const bindChildren = (
// Set up a reactive effect so that changes to the children array are diffed and applied.
const cancelArrayEffect = effect(
children,
(childrenVal) => updateChildren(childrenVal),
(childrenVal) => {
// If the value contains RemoteCells (from worker rehydration), resolve them first
if (needsRemoteCellResolution(childrenVal)) {
resolveRemoteCells(childrenVal).then((resolved) => {
updateChildren(
resolved as RenderNode | RenderNode[] | undefined | null,
);
});
// Render empty while waiting for resolution
updateChildren(undefined);
return undefined;
}
updateChildren(
childrenVal as RenderNode | RenderNode[] | undefined | null,
);
return undefined;
},
);

// Return a cancel function that tears down the effect and cleans up any rendered nodes.
Expand All @@ -368,7 +446,7 @@ const bindProps = (
const setProperty = options.setProp ?? setProp;
const [cancel, addCancel] = useCancelGroup();
for (const [propKey, propValue] of Object.entries(props)) {
if (isCell(propValue)) {
if (isRenderableCell(propValue)) {
// If prop is an event, we need to add an event listener
if (isEventProp(propKey)) {
const key = cleanEventProp(propKey);
Expand Down Expand Up @@ -526,10 +604,10 @@ const sanitizeScripts = (node: VNode): VNode | null => {
if (node.name === "script") {
return null;
}
if (!isCell(node.props) && !isObject(node.props)) {
if (!isRenderableCell(node.props) && !isObject(node.props)) {
node = { ...node, props: {} };
}
if (!isCell(node.children) && !Array.isArray(node.children)) {
if (!isRenderableCell(node.children) && !Array.isArray(node.children)) {
node = { ...node, children: [] };
}

Expand Down Expand Up @@ -642,3 +720,12 @@ function isSelectElement(value: unknown): value is HTMLSelectElement {
typeof value.tagName === "string" &&
value.tagName.toUpperCase() === "SELECT");
}

type RenderableCell = {
send(value: unknown): void;
};
function isRenderableCell(value: unknown): value is RenderableCell {
// Check for any object with a send() method (Cell, RemoteCell, or Stream)
return !!value && typeof value === "object" && "send" in value &&
typeof (value as RenderableCell).send === "function";
}
Loading
Loading