Skip to content

Commit 0d1a6f5

Browse files
committed
Refactor CharmRunner.tsx React wrapper
1 parent 399e0d0 commit 0d1a6f5

File tree

3 files changed

+130
-85
lines changed

3 files changed

+130
-85
lines changed
Lines changed: 117 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,130 @@
1-
import React, { useRef, useEffect } from "react";
2-
import { effect } from "@commontools/runner";
3-
import type { DocImpl } from "@commontools/runner";
4-
import { createRoot, Root } from "react-dom/client";
5-
import { UI } from "@commontools/builder";
6-
7-
export interface CharmRunnerProps {
8-
// Accept either a full reactive DocImpl or a plain charm with a ui prop.
9-
charm: DocImpl<any> | { ui: React.ReactNode };
1+
import React, { useRef } from "react";
2+
import { render } from "@commontools/html";
3+
import { addCharms, Charm, runPersistent } from "@commontools/charm";
4+
import { effect, idle, run } from "@commontools/runner";
5+
6+
interface CharmRunnerProps {
7+
charmImport: () => Promise<any>;
8+
argument?: any;
9+
autoLoad?: boolean;
10+
className?: string;
1011
}
1112

12-
export default function CharmRunner({ charm }: CharmRunnerProps) {
13+
export function CharmRunner({
14+
charmImport,
15+
argument,
16+
autoLoad = false,
17+
className = "",
18+
}: CharmRunnerProps) {
1319
const containerRef = useRef<HTMLDivElement>(null);
14-
const rootRef = useRef<Root | null>(null);
15-
16-
useEffect(() => {
17-
// Helper: updates the container with the new view, re-using the root.
18-
const updateContainer = (view: any) => {
19-
if (containerRef.current) {
20-
if (!rootRef.current) {
21-
rootRef.current = createRoot(containerRef.current);
22-
}
23-
if (React.isValidElement(view)) {
24-
rootRef.current.render(view);
25-
} else {
26-
// If view is raw html, update innerHTML
27-
containerRef.current.innerHTML = view;
28-
}
29-
}
30-
};
20+
const [error, setError] = React.useState<Error | null>(null);
21+
const [isLoading, setIsLoading] = React.useState(false);
22+
const charmInstance = useRef<any>(null);
23+
const cleanupFns = useRef<Array<() => void>>([]);
24+
// Add a mounting key to help us detect remounts
25+
const mountingKey = useRef(0);
3126

32-
// If the charm doesn't have asCell (i.e. not reactive), immediately update.
33-
if (typeof (charm as any).asCell !== "function") {
34-
updateContainer((charm as { ui: React.ReactNode }).ui);
35-
return;
27+
const cleanup = () => {
28+
cleanupFns.current.forEach((fn) => fn());
29+
cleanupFns.current = [];
30+
if (charmInstance.current) {
31+
charmInstance.current = null;
32+
}
33+
if (containerRef.current) {
34+
containerRef.current.innerHTML = "";
3635
}
36+
};
37+
38+
const loadAndRunCharm = React.useCallback(async () => {
39+
if (!containerRef.current) return;
40+
41+
// Increment mounting key for this attempt
42+
const currentMountKey = ++mountingKey.current;
43+
44+
cleanup();
45+
setIsLoading(true);
46+
setError(null);
47+
48+
try {
49+
const module = await charmImport();
50+
const factory = module.default;
51+
52+
if (!factory) {
53+
throw new Error("Invalid charm module: missing default export");
54+
}
55+
56+
// Check if this is still the most recent mounting attempt
57+
if (currentMountKey !== mountingKey.current) {
58+
return;
59+
}
60+
61+
const charm = await runPersistent(factory);
3762

38-
// If charm is reactive, subscribe to its UI cell.
39-
const unsubscribe = effect((charm as DocImpl<any>).asCell(UI), (view: any) => {
40-
if (!view) {
41-
console.warn("No UI for charm", charm);
63+
// Check again after async operation
64+
if (currentMountKey !== mountingKey.current) {
4265
return;
4366
}
44-
updateContainer(view);
45-
});
4667

68+
addCharms([charm]);
69+
70+
await idle();
71+
run(undefined, argument, charm);
72+
await idle();
73+
74+
// Final check before setting up effects
75+
if (currentMountKey !== mountingKey.current) {
76+
return;
77+
}
78+
79+
const cleanupCharm = effect(charm.asCell<Charm>(), (charm) => {
80+
const cleanupUI = effect(charm["$UI"], (view) => {
81+
if (containerRef.current) {
82+
render(containerRef.current, view);
83+
}
84+
});
85+
cleanupFns.current.push(cleanupUI);
86+
});
87+
cleanupFns.current.push(cleanupCharm);
88+
89+
charmInstance.current = charm;
90+
} catch (err) {
91+
if (currentMountKey === mountingKey.current) {
92+
setError(err as Error);
93+
}
94+
} finally {
95+
if (currentMountKey === mountingKey.current) {
96+
setIsLoading(false);
97+
}
98+
}
99+
}, [charmImport, argument]);
100+
101+
// Clean up on unmount
102+
React.useEffect(() => {
47103
return () => {
48-
unsubscribe();
104+
mountingKey.current++;
105+
cleanup();
49106
};
50-
}, [charm]);
107+
}, []);
108+
109+
// Handle autoLoad
110+
React.useEffect(() => {
111+
if (autoLoad) {
112+
loadAndRunCharm();
113+
}
114+
}, [autoLoad, loadAndRunCharm]);
115+
116+
// Handle prop changes
117+
React.useEffect(() => {
118+
if (charmInstance.current) {
119+
loadAndRunCharm();
120+
}
121+
}, [argument, charmImport]);
51122

52-
return <div ref={containerRef} />;
123+
return (
124+
<div className={className}>
125+
{isLoading && <div>Loading...</div>}
126+
{error && <div>Error loading charm</div>}
127+
<div ref={containerRef}></div>
128+
</div>
129+
);
53130
}

typescript/packages/jumble/src/main.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import React from "react";
21
import { StrictMode } from "react";
32
import { createRoot } from "react-dom/client";
43
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
Lines changed: 13 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
// This is all you need to import/register the @commontools/ui web components
22
import "@commontools/ui";
3-
import React, { useRef } from "react";
4-
import { type Charm, runPersistent, addCharms } from "@commontools/charm";
5-
import { effect, idle, run } from "@commontools/runner";
6-
import { render } from "@commontools/html";
73
import { setIframeContextHandler } from "@commontools/iframe-sandbox";
84
import { Action, ReactivityLog, addAction, removeAction } from "@commontools/runner";
5+
import { CharmRunner } from "@/components/CharmRunner";
6+
import { useState } from "react";
97

108
// FIXME(ja): perhaps this could be in common-charm? needed to enable iframe with sandboxing
119
setIframeContextHandler({
@@ -28,53 +26,24 @@ setIframeContextHandler({
2826
});
2927

3028
export default function Shell() {
31-
const containerRef = useRef<HTMLDivElement>(null);
29+
const [count, setCount] = useState(0);
3230

33-
const handleLoadSmolCharm = async () => {
34-
try {
35-
// load the recipe, BUT you can't use JSX because
36-
// JSX here would be react JSX, not common/html JSX
37-
// even though the recipe imports our `h` function
38-
const mod = await import("@/recipes/smol.tsx");
39-
const smolFactory = mod.default;
40-
41-
// run the charm (this makes the logic go, cells, etc)
42-
// but nothing about rendering...
43-
const charm = await runPersistent(smolFactory);
44-
addCharms([charm]);
45-
46-
await idle();
47-
run(undefined, undefined, charm);
48-
await idle();
49-
50-
// connect the cells of the charm (reactive docs) and the
51-
// view (recipe VDOM) to be rendered using common/html
52-
// into a specific DOM element (created in react)
53-
console.log("charm", JSON.stringify(charm, null, 2));
54-
effect(charm.asCell<Charm>(), (charm) => {
55-
console.log("charm", JSON.stringify(charm, null, 2));
56-
effect(charm['$UI'], (view) => {
57-
console.log("view", JSON.stringify(view, null, 2));
58-
render(containerRef.current as HTMLElement, view);
59-
});
60-
});
61-
} catch (error) {
62-
console.error("Failed to load counter charm", error);
63-
}
31+
const incrementCount = () => {
32+
setCount((c) => c + 1);
6433
};
6534

6635
return (
6736
<div className="h-full relative">
68-
<button
69-
onClick={handleLoadSmolCharm}
70-
className="mt-4 ml-4 px-4 py-2 bg-green-500 text-white rounded"
71-
>
72-
Load & Run Smol Charm
37+
<button onClick={incrementCount} className="mb-4 px-4 py-2 bg-blue-500 text-white rounded">
38+
Increment Count ({count})
7339
</button>
7440

75-
<div className="border border-red-500 mt-4 p-2">
76-
<div ref={containerRef}></div>
77-
</div>
41+
<CharmRunner
42+
charmImport={() => import("@/recipes/smol.tsx")}
43+
argument={{ count }}
44+
className="border border-red-500 mt-4 p-2"
45+
autoLoad
46+
/>
7847
</div>
7948
);
8049
}

0 commit comments

Comments
 (0)