Skip to content
Merged
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
152 changes: 152 additions & 0 deletions docs/common/wip/KeyLearnings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# Key Learnings for Pattern Development

This document captures important learnings discovered during pattern development. These will be reviewed and edited for prompt engineering purposes.

---

## Getting Cell Access for Mutation

**Goal:** Mutate state from inline event handlers in JSX.

**Solution:** Declare `Cell<>` around the field in your Input interface. This gives you access to `.set()`, `.get()`, `.update()`, etc.

```typescript
// ✅ CORRECT: Declare Cell<> in Input to get mutation access
interface Input {
count: Cell<Default<number, 0>>; // Has .set(), .get(), .update()
}

export default recipe<Input, Output>(({ count }) => {
return {
[UI]: (
<div>
<div>{count}</div>
{/* Inline mutation works! */}
<ct-button onClick={() => count.set(count.get() + 1)}>
+1
</ct-button>
</div>
),
count,
};
});
```

**Without Cell<>** - read-only (no mutation methods):

```typescript
// ❌ Without Cell<> wrapper - read-only
interface Input {
count: Default<number, 0>; // No .set(), .get() methods
}

// This will fail:
<ct-button onClick={() => count.set(count.get() + 1)}> // ERROR!
```

**Error you'll see if you forget Cell<>:**
```
TypeError: count.set is not a function
```

**Key insight:** `Cell<>` in the Input interface indicates write intent. It tells the runtime to provide a Cell reference instead of an opaque ref. Everything is reactive by default - `Cell<>` only signals that you'll call mutation methods.

---

## Exposing Actions via Handlers (for Cross-Charm Calling)

**Goal:** Make a charm's actions callable by **other linked charms** (not just within the same charm).

**When to use `handler()` vs `Cell<>` in Input:**

| Need | Solution |
|------|----------|
| Mutate state within same charm | Declare `Cell<T>` in Input (see above) |
| Expose action for OTHER linked charms | Use `handler()` + return as Stream |

**Pattern for cross-charm actions:**
1. Define handlers at module level using `handler<EventType, StateType>()`
2. Return handlers bound to state in the recipe's return object
3. Cast to `Stream<T>` in the output interface

```typescript
import { Cell, Default, handler, NAME, recipe, Stream, UI } from "commontools";

interface Output {
count: number;
increment: Stream<void>; // Action exposed as Stream
decrement: Stream<void>;
setCount: Stream<{ value: number }>;
}

// 1. Define handlers
const increment = handler<unknown, { count: Cell<number> }>(
(_event, { count }) => {
count.set(count.get() + 1);
},
);

const setCount = handler<{ value: number }, { count: Cell<number> }>(
(event, { count }) => {
count.set(event?.value ?? 0);
},
);

export default recipe<Input, Output>("Action Counter", ({ count }) => {
return {
[NAME]: "Action Counter",
[UI]: (
<div>
<ct-button onClick={increment({ count })}>+1</ct-button>
</div>
),
count,
// 2. Return handlers bound to state, cast to Stream
increment: increment({ count }) as unknown as Stream<void>,
setCount: setCount({ count }) as unknown as Stream<{ value: number }>,
};
});
```

**Key insight:** Handlers become callable action streams when returned in the output. The `as unknown as Stream<T>` cast tells TypeScript that this bound handler will be used as a stream by consumers.

---

## Consuming Actions via Streams

**Goal:** Call actions exposed by a linked charm.

### Recommended: Whole Charm Linking (Declare Only What You Need)

Link the entire source charm to a single input field. In your type, **only declare the fields you actually use** - you don't need to mirror the entire source charm's interface.

```typescript
// Only declare what you need - not the full CounterCharm interface
interface LinkedCounter {
count: number; // Only if you need to display it
increment: Stream<void>; // Only the actions you'll call
setCount: Stream<{ value: number }>;
}

interface Input {
counter: Default<LinkedCounter | null, null>;
}

// Link with: ct charm link <counter-id> <this-id>/counter

// Usage in JSX - inline .send():
<ct-button onClick={() => counter.increment.send()}>+1</ct-button>
<ct-button onClick={() => counter.setCount.send({ value: 0 })}>Reset</ct-button>

// Can also read data:
<div>Count: {counter.count}</div>
```

**Advantages:**
- Single link gives access to data and actions
- Type documents your actual dependencies
- No need to maintain a full mirror of the source charm's interface

**Key insight:** `Stream<T>` types have only `.send()` - no `.get()` or `.set()`. They're write-only channels for triggering actions.

---
2 changes: 2 additions & 0 deletions packages/runner/src/builder/opaque-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ function opaqueRefWithCell<T>(
cell.setInitialValue(value as T);
}

frame.opaqueRefs.add(cell);

// Use the cell's built-in method to get a proxied OpaqueRef
return cell.getAsOpaqueRefProxy();
}
Expand Down
6 changes: 3 additions & 3 deletions packages/runner/src/builder/recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ function factoryFromRecipe<T, R>(
});

// First from results
if (isRecord(outputs)) {
if (isRecord(outputs) && !isCell(outputs)) {
Object.entries(outputs).forEach(([key, value]: [string, unknown]) => {
if (isCell(value)) {
const exported = value.export();
Expand Down Expand Up @@ -263,8 +263,8 @@ function factoryFromRecipe<T, R>(
});
});

// [For unsafe bindings] Also collect otherwise disconnected cells and nodes,
// since they might only be mentioned via a code closure in a lifted function.
// Also collect otherwise disconnected cells and nodes, e.g. those that are
// assigned to cells via .set or .push and aren't otherwise connected.
getTopFrame()?.opaqueRefs.forEach((ref) => collectCellsAndNodes(ref));

// Then assign paths on the recipe cell for all cells. For now we just assign
Expand Down
4 changes: 2 additions & 2 deletions packages/runner/src/cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ export class CellImpl<T> implements ICell<T>, IStreamable<T> {
}

// Create an entity ID from the cause, including the frame's
const id = toURI(createRef({ frame: cause }, cause));
const id = toURI(createRef({ frame: cause }, this._frame.cause));

// Populate the id in the shared causeContainer
// All siblings will see this update
Expand Down Expand Up @@ -1074,7 +1074,7 @@ export class CellImpl<T> implements ICell<T>, IStreamable<T> {
: this._initialValue,
name: this._causeContainer.cause as string | undefined,
external: this._link.id
? this.getAsWriteRedirectLink({
? this.getAsLink({
baseSpace: this._frame.space,
includeSchema: true,
})
Expand Down
59 changes: 40 additions & 19 deletions packages/runner/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
areNormalizedLinksSame,
createSigilLinkFromParsedLink,
isLink,
isSigilLink,
isWriteRedirectLink,
type NormalizedFullLink,
parseLink,
Expand Down Expand Up @@ -1003,7 +1004,7 @@ export class Runner implements IRunner {
const result = fn(argument);

const postRun = (result: any) => {
if (containsOpaqueRef(result)) {
if (containsOpaqueRef(result) || frame.opaqueRefs.size > 0) {
const resultRecipe = recipeFromFrame(
"event handler result",
undefined,
Expand Down Expand Up @@ -1094,7 +1095,7 @@ export class Runner implements IRunner {
const result = fn(argument);

const postRun = (result: any) => {
if (containsOpaqueRef(result)) {
if (containsOpaqueRef(result) || frame.opaqueRefs.size > 0) {
const resultRecipe = recipeFromFrame(
"action result",
undefined,
Expand Down Expand Up @@ -1276,24 +1277,44 @@ export class Runner implements IRunner {
processCell,
);
const inputs = unwrapOneLevelAndBindtoDoc(inputBindings, processCell);
const resultCell = this.runtime.getCell(
processCell.space,
{
recipe: module.implementation,
parent: processCell.entityId,
inputBindings,
outputBindings,
},
undefined,
tx,
);

// If output bindings is a link to a non-redirect cell,
// use that instead of creating a new cell.
let resultCell;
let sendToBindings: boolean;
if (isSigilLink(outputBindings) && !isWriteRedirectLink(outputBindings)) {
resultCell = this.runtime.getCellFromLink(
parseLink(outputBindings, processCell),
recipeImpl.resultSchema,
tx,
);
sendToBindings = false;
} else {
resultCell = this.runtime.getCell(
processCell.space,
{
recipe: module.implementation,
parent: processCell.entityId,
inputBindings,
outputBindings,
},
recipeImpl.resultSchema,
tx,
);
sendToBindings = true;
}

this.run(tx, recipeImpl, inputs, resultCell);
sendValueToBinding(
tx,
processCell,
outputBindings,
resultCell.getAsLink({ base: processCell }),
);

if (sendToBindings) {
sendValueToBinding(
tx,
processCell,
outputBindings,
resultCell.getAsLink({ base: processCell }),
);
}

// TODO(seefeld): Make sure to not cancel after a recipe is elevated to a
// charm, e.g. via navigateTo. Nothing is cancelling right now, so leaving
// this as TODO.
Expand Down
Loading
Loading