11import { isObject , isRecord } from "@commontools/utils/types" ;
22import {
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" ;
1211import { isVNode , type Props , type RenderNode , type VNode } from "./jsx.ts" ;
1312import * 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
2827export const vdomSchema : JSONSchema = {
@@ -60,25 +59,32 @@ export const vdomSchema: JSONSchema = {
6059 */
6160export 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 */
132138const 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+
219254const 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 it’ s 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 there’ s 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+ }
0 commit comments