Skip to content
Merged
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
184 changes: 87 additions & 97 deletions packages/patterns/charm-ref-in-cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,73 +3,55 @@ import {
Cell,
cell,
createCell,
Default,
derive,
h,
handler,
ifElse,
lift,
NAME,
navigateTo,
recipe,
toSchema,
UI,
} from "commontools";

// full recipe state
interface RecipeState {
charm: any;
cellRef: Cell<{ charm: any }>;
isInitialized: Cell<boolean>;
}
const RecipeStateSchema = toSchema<RecipeState>();

Copy link
Contributor Author

@ellyxir ellyxir Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

im not sure how to get isInitialized to get defaulted so that i dont have to create it with cell(), i tried passing a default here:

+const RecipeStateSchema = toSchema<RecipeState>({
+ default: { isInitialized: false },
+});

but when i call the handler, it still requires the parameter, even if i made it optional.

Copy link
Contributor

@ubik2 ubik2 Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you want your initial declaration to be:

interface RecipeState {
  charm: any;
  cellRef: Cell<{ charm: any }>;
  isInitialized: Cell<Default<boolean, false>>;
}

Edit: actually, this may not be what you want, since you don't want to have to initialize it as a cell.

Copy link
Contributor

@ubik2 ubik2 Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To merge options, I think you would have wanted:

{
  "properties": {
    "isInitialized": {
      "default": false
     }
  }
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Default<boolean, false> option would work. But it really does is replace undefined with false, so if your code says if (isInitialized.get()) it doesn't matter.

And yes, you have to call the lift with a cell, but it can be myLiftedFunction({ foo, bar, isInitalized: cell(false)}) or technically just cell().

The underlying reason is an asymmetry we should anyway resolve: When a recipe gets static data, it just makes a new cell, so it's read/write, but for lift static inputs (and hence also defaults) are read-only. We should change that, and then you wouldn't need the cell creation in this case (<=> the cell is created implicitly by the input that wasn't passed in).

// what we pass into the recipe as input
// wraps the charm reference in an object { charm: any }
// instead of storing the charm directly. This avoids a "pointer of pointers"
// error that occurs when a Cell directly contains another Cell/charm reference.
type RecipeInOutput = {
cellRef: Default<{ charm: any }, { charm: null }>;
};

// the simple charm (to which we'll store a reference within a cell)
const SimpleRecipe = recipe("Simple Recipe", () => ({
[NAME]: "Some Simple Recipe",
[UI]: <div>Some Simple Recipe</div>,
const SimpleRecipe = recipe<{ id: string }>("Simple Recipe", ({ id }) => ({
[NAME]: derive(id, (idValue) => `SimpleRecipe: ${idValue}`),
[UI]: <div>Simple Recipe id {id}</div>,
}));

// We are going to dynamically create a charm via the `createCounter` function
// and store it (the reference to it) in a cell. We create the cell here.
// There are a few ways to do this:
// - Default values
// - cell()
// - createCell within a lift or derive (we'll use this for now)
// Use isInitialized and storedCellRef to ensure we only create the cell once
const createCellRef = lift(
{
type: "object",
properties: {
isInitialized: { type: "boolean", default: false, asCell: true },
storedCellRef: { type: "object", asCell: true },
},
},
undefined,
({ isInitialized, storedCellRef }) => {
if (!isInitialized.get()) {
console.log("Creating cellRef");
const newCellRef = createCell(undefined, "cellRef");
storedCellRef.set(newCellRef);
isInitialized.set(true);
return {
cellRef: newCellRef,
};
} else {
console.log("cellRef already initialized");
}
// If already initialized, return the stored cellRef
return {
cellRef: storedCellRef,
};
},
);

// this will be called whenever charm or cellRef changes
// pass isInitialized to make sure we dont call this each time
// we change cellRef, otherwise creates a loop
// also, we need to only navigateTo if not initialized so that
// the other lifts we created compete and try to
// navigateTo at the same time.
// note there is a separate isInitialized for each created charm
// Lift that stores a charm reference in a cell and navigates to it.
// Triggered when any input changes (charm, cellRef, or isInitialized).
//
// The isInitialized flag prevents infinite loops:
// - Without it: lift runs → sets cellRef → cellRef changes → lift runs again → loop
// - With it: lift runs once → sets isInitialized → subsequent runs skip the logic
//
// Each handler invocation creates its own isInitialized cell, ensuring
// independent tracking for multiple charm creations.
//
// We use a lift() here instead of executing inside of a handler because
// we want to know the passed in charm is initialized
const storeCharmAndNavigate = lift(
{
type: "object",
properties: {
charm: { type: "object" },
cellRef: { type: "object", asCell: true },
isInitialized: { type: "boolean", asCell: true },
},
},
RecipeStateSchema,
undefined,
({ charm, cellRef, isInitialized }) => {
if (!isInitialized.get()) {
Expand All @@ -78,7 +60,7 @@ const storeCharmAndNavigate = lift(
"storeCharmAndNavigate storing charm:",
JSON.stringify(charm),
);
cellRef.set(charm);
cellRef.set({ charm });
isInitialized.set(true);
return navigateTo(charm);
} else {
Expand All @@ -91,64 +73,72 @@ const storeCharmAndNavigate = lift(
},
);

// create a simple subrecipe
// we will save a reference to it in a cell so make it as simple as
// possible.
// we then call navigateTo() which will redirect the
// browser to the newly created charm
const createSimpleRecipe = handler<unknown, { cellRef: Cell<any> }>(
// Handler that creates a new charm instance and stores its reference.
// 1. Creates a local isInitialized cell to track one-time execution
// 2. Instantiates SimpleRecipe charm
// 3. Uses storeCharmAndNavigate lift to save reference and navigate
const createSimpleRecipe = handler<unknown, { cellRef: Cell<{ charm: any }> }>(
(_, { cellRef }) => {
const isInitialized = cell(false);

// Create a random 5-digit ID
const randomId = Math.floor(10000 + Math.random() * 90000).toString();

// create the charm
const charm = SimpleRecipe({});
const charm = SimpleRecipe({ id: randomId });

// store the charm ref in a cell (pass isInitialized to prevent recursive calls)
return storeCharmAndNavigate({ charm, cellRef, isInitialized });
},
);

// Handler to navigate to the stored charm (just console.log for now)
const goToStoredCharm = handler<unknown, { cellRef: Cell<any> }>(
const goToStoredCharm = handler<unknown, { cellRef: Cell<{ charm: any }> }>(
(_, { cellRef }) => {
console.log("goToStoredCharm clicked");
return navigateTo(cellRef);
const cellValue = cellRef.get();
if (!cellValue.charm) {
console.error("No charm found in cell!");
return;
}
return navigateTo(cellValue.charm);
},
);

// create the named cell inside the recipe body, so we do it just once
export default recipe("Launcher", () => {
// cell to store to the last charm we created
const { cellRef } = createCellRef({
isInitialized: cell(false),
storedCellRef: cell(),
});

return {
[NAME]: "Launcher",
[UI]: (
<div>
export default recipe<RecipeInOutput, RecipeInOutput>(
"Launcher",
({ cellRef }) => {
return {
[NAME]: "Launcher",
[UI]: (
<div>
Stored charm ID: {derive(cellRef, (innerCell) => {
if (!innerCell) return "undefined";
return innerCell[UI];
})}
<div>
Stored charm ID: {derive(cellRef, (innerCell) => {
if (!innerCell) return "undefined";
if (!innerCell.charm) return "no charm stored yet";
return innerCell.charm[UI] || "charm has no UI";
})}
</div>
<ct-button
onClick={createSimpleRecipe({ cellRef })}
>
Create Sub Charm
</ct-button>

{ifElse(
cellRef.charm,
(
<ct-button onClick={goToStoredCharm({ cellRef })}>
Go to Stored Charm
</ct-button>
),
(
<div>no subcharm</div>
),
)}
</div>
<ct-button
onClick={createSimpleRecipe({ cellRef })}
>
Create Sub Charm
</ct-button>
{derive(cellRef, (innerCell) => {
if (!innerCell) return "no subcharm yet!";
return (
<ct-button onClick={goToStoredCharm({ cellRef: innerCell })}>
Go to Stored Charm
</ct-button>
);
})}
</div>
),
cellRef,
};
});
),
cellRef,
};
},
);