Skip to content

Commit 02f501d

Browse files
seefeldbclaude
andauthored
feat(runner): Add sample() method to Cell for non-reactive reads (commontoolsinc#2175)
Adds a new `sample()` method to Cell that reads the current value without creating a reactive dependency. Unlike `get()`, calling `sample()` inside a handler or lift won't cause it to re-run when the sampled cell changes. This is implemented using a NonReactiveTransaction wrapper that adds `ignoreReadForScheduling` meta to all reads. Child cells created during the sample operation still behave normally with reactive reads. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <[email protected]>
1 parent 78c2e50 commit 02f501d

File tree

6 files changed

+264
-10
lines changed

6 files changed

+264
-10
lines changed

packages/api/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ export interface IAnyCell<T> {
7676
*/
7777
export interface IReadable<T> {
7878
get(): Readonly<T>;
79+
/**
80+
* Read the cell's current value without creating a reactive dependency.
81+
* Unlike `get()`, calling `sample()` inside a lift won't cause the lift
82+
* to re-run when this cell's value changes.
83+
*/
84+
sample(): Readonly<T>;
7985
}
8086

8187
/**

packages/runner/src/cell.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import type {
6969
IExtendedStorageTransaction,
7070
IReadOptions,
7171
} from "./storage/interface.ts";
72+
import { NonReactiveTransaction } from "./storage/extended-storage-transaction.ts";
7273
import { fromURI } from "./uri-utils.ts";
7374
import { ContextualFlowControl } from "./cfc.ts";
7475

@@ -206,6 +207,7 @@ export type { MemorySpace } from "@commontools/memory/interface";
206207

207208
const cellMethods = new Set<keyof ICell<unknown>>([
208209
"get",
210+
"sample",
209211
"set",
210212
"send",
211213
"update",
@@ -519,6 +521,31 @@ export class CellImpl<T> implements ICell<T>, IStreamable<T> {
519521
return validateAndTransform(this.runtime, this.tx, this.link, this.synced);
520522
}
521523

524+
/**
525+
* Read the cell's current value without creating a reactive dependency.
526+
* Unlike `get()`, calling `sample()` inside a handler won't cause the handler
527+
* to re-run when this cell's value changes.
528+
*
529+
* Use this when you need to read a value but don't want changes to that value
530+
* to trigger re-execution of the current reactive context.
531+
*/
532+
sample(): Readonly<T> {
533+
if (!this.synced) this.sync(); // No await, just kicking this off
534+
535+
// Wrap the transaction with NonReactiveTransaction to make all reads
536+
// non-reactive. Child cells created during validateAndTransform will
537+
// use the original transaction (via getTransactionForChildCells).
538+
const readTx = this.runtime.readTx(this.tx);
539+
const nonReactiveTx = new NonReactiveTransaction(readTx);
540+
541+
return validateAndTransform(
542+
this.runtime,
543+
nonReactiveTx,
544+
this.link,
545+
this.synced,
546+
);
547+
}
548+
522549
set(
523550
newValue: AnyCellWrapping<T> | T,
524551
onCommit?: (tx: IExtendedStorageTransaction) => void,

packages/runner/src/schema.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { type JSONSchema, type JSONValue } from "./builder/types.ts";
77
import { createCell, isCell } from "./cell.ts";
88
import { readMaybeLink, resolveLink } from "./link-resolution.ts";
99
import { type IExtendedStorageTransaction } from "./storage/interface.ts";
10+
import { getTransactionForChildCells } from "./storage/extended-storage-transaction.ts";
1011
import { type IRuntime } from "./runtime.ts";
1112
import {
1213
createDataCellURI,
@@ -141,7 +142,7 @@ export function processDefaultValue(
141142
schema: mergeDefaults(resolvedSchema, defaultValue),
142143
rootSchema,
143144
},
144-
tx,
145+
getTransactionForChildCells(tx),
145146
);
146147
}
147148
}
@@ -314,7 +315,9 @@ function annotateWithBackToCellSymbols(
314315
) {
315316
// Non-enumerable, so that {...obj} won't copy these symbols
316317
Object.defineProperty(value, toCell, {
317-
value: () => createCell(runtime, link, tx),
318+
// Use getTransactionForChildCells so that if this was called from sample(),
319+
// the resulting cell is still reactive
320+
value: () => createCell(runtime, link, getTransactionForChildCells(tx)),
318321
enumerable: false,
319322
});
320323
Object.freeze(value);
@@ -438,11 +441,11 @@ export function validateAndTransform(
438441
schema: newSchema,
439442
rootSchema,
440443
},
441-
tx,
444+
getTransactionForChildCells(tx),
442445
);
443446
}
444447
}
445-
return createCell(runtime, link, tx);
448+
return createCell(runtime, link, getTransactionForChildCells(tx));
446449
}
447450

448451
// If there is no schema, return as raw data via query result proxy
@@ -847,12 +850,7 @@ export function validateAndTransform(
847850
? { ...(value as Record<string, unknown>) }
848851
: [...(value as unknown[])];
849852
seen.push([seenKey, cloned]);
850-
return annotateWithBackToCellSymbols(
851-
cloned,
852-
runtime,
853-
link,
854-
tx,
855-
);
853+
return annotateWithBackToCellSymbols(cloned, runtime, link, tx);
856854
} else {
857855
seen.push([seenKey, value]);
858856
return value;

packages/runner/src/storage/extended-storage-transaction.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,3 +214,123 @@ export class ExtendedStorageTransaction implements IExtendedStorageTransaction {
214214
this.commitCallbacks.add(callback);
215215
}
216216
}
217+
218+
/**
219+
* A wrapper around an IExtendedStorageTransaction that adds ignoreReadForScheduling
220+
* meta to all read operations. This makes reads non-reactive - they won't trigger
221+
* re-execution of handlers when the read values change.
222+
*
223+
* Used by Cell.sample() to read values without subscribing to changes.
224+
*/
225+
export class NonReactiveTransaction implements IExtendedStorageTransaction {
226+
constructor(private wrapped: IExtendedStorageTransaction) {}
227+
228+
/**
229+
* Get the transaction to use for creating child cells.
230+
* Child cells should be reactive, so this returns the unwrapped transaction.
231+
*/
232+
getTransactionForChildCells(): IExtendedStorageTransaction {
233+
return this.wrapped;
234+
}
235+
236+
get tx(): IStorageTransaction {
237+
return this.wrapped.tx;
238+
}
239+
240+
get journal(): ITransactionJournal {
241+
return this.wrapped.journal;
242+
}
243+
244+
status(): StorageTransactionStatus {
245+
return this.wrapped.status();
246+
}
247+
248+
reader(space: MemorySpace): Result<ITransactionReader, ReaderError> {
249+
return this.wrapped.reader(space);
250+
}
251+
252+
private addNonReactiveMeta(options?: IReadOptions): IReadOptions {
253+
return {
254+
...options,
255+
meta: { ...options?.meta, ...ignoreReadForScheduling },
256+
};
257+
}
258+
259+
read(
260+
address: IMemorySpaceAddress,
261+
options?: IReadOptions,
262+
): Result<IAttestation, ReadError> {
263+
return this.wrapped.read(address, this.addNonReactiveMeta(options));
264+
}
265+
266+
readOrThrow(
267+
address: IMemorySpaceAddress,
268+
options?: IReadOptions,
269+
): JSONValue | undefined {
270+
return this.wrapped.readOrThrow(address, this.addNonReactiveMeta(options));
271+
}
272+
273+
readValueOrThrow(
274+
address: IMemorySpaceAddress,
275+
options?: IReadOptions,
276+
): JSONValue | undefined {
277+
return this.wrapped.readValueOrThrow(
278+
address,
279+
this.addNonReactiveMeta(options),
280+
);
281+
}
282+
283+
writer(space: MemorySpace): Result<ITransactionWriter, WriterError> {
284+
return this.wrapped.writer(space);
285+
}
286+
287+
write(
288+
address: IMemorySpaceAddress,
289+
value: JSONValue | undefined,
290+
): Result<IAttestation, WriteError | WriterError> {
291+
return this.wrapped.write(address, value);
292+
}
293+
294+
writeOrThrow(
295+
address: IMemorySpaceAddress,
296+
value: JSONValue | undefined,
297+
): void {
298+
return this.wrapped.writeOrThrow(address, value);
299+
}
300+
301+
writeValueOrThrow(
302+
address: IMemorySpaceAddress,
303+
value: JSONValue | undefined,
304+
): void {
305+
return this.wrapped.writeValueOrThrow(address, value);
306+
}
307+
308+
abort(reason?: unknown): Result<Unit, InactiveTransactionError> {
309+
return this.wrapped.abort(reason);
310+
}
311+
312+
commit(): Promise<Result<Unit, CommitError>> {
313+
return this.wrapped.commit();
314+
}
315+
316+
addCommitCallback(callback: (tx: IExtendedStorageTransaction) => void): void {
317+
return this.wrapped.addCommitCallback(callback);
318+
}
319+
}
320+
321+
/**
322+
* Helper function to get the transaction to use for creating child cells from a
323+
* potentially wrapped NonReactiveTransaction. If the transaction is not wrapped,
324+
* returns it as-is.
325+
*
326+
* Used when creating child cells that should be reactive even when the parent
327+
* read was non-reactive (e.g., in Cell.sample()).
328+
*/
329+
export function getTransactionForChildCells(
330+
tx: IExtendedStorageTransaction | undefined,
331+
): IExtendedStorageTransaction | undefined {
332+
if (tx instanceof NonReactiveTransaction) {
333+
return tx.getTransactionForChildCells();
334+
}
335+
return tx;
336+
}

packages/runner/src/storage/interface.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,3 +921,6 @@ export interface IAttestation {
921921
readonly address: IMemoryAddress;
922922
readonly value?: JSONValue;
923923
}
924+
925+
// Re-export NonReactiveTransaction from implementation
926+
export { NonReactiveTransaction } from "./extended-storage-transaction.ts";

packages/runner/test/recipes.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1443,4 +1443,104 @@ describe("Recipe Runner", () => {
14431443
const result2 = charmCell.withTx(tx).get();
14441444
expect(result2.list.get()).toEqual([{ text: "hello" }]);
14451445
});
1446+
1447+
it("should support non-reactive reads with sample()", async () => {
1448+
let liftRunCount = 0;
1449+
1450+
// A lift that takes two parameters:
1451+
// - first: a regular number (reactive)
1452+
// - second: a Cell that we'll read with sample() (non-reactive)
1453+
const computeWithSample = lift(
1454+
// Input schema: first is reactive, second is asCell
1455+
{
1456+
type: "object",
1457+
properties: {
1458+
first: { type: "number" },
1459+
second: { type: "number", asCell: true },
1460+
},
1461+
required: ["first", "second"],
1462+
} as const satisfies JSONSchema,
1463+
// Output schema
1464+
{ type: "number" },
1465+
// The lift function
1466+
({ first, second }) => {
1467+
liftRunCount++;
1468+
// Use sample() to read the second cell non-reactively
1469+
const secondValue = second.sample();
1470+
return first + secondValue;
1471+
},
1472+
);
1473+
1474+
const sampleRecipe = recipe<{ first: number; second: number }>(
1475+
"Sample Recipe",
1476+
({ first, second }) => {
1477+
return { result: computeWithSample({ first, second }) };
1478+
},
1479+
);
1480+
1481+
// Create input cells
1482+
const firstCell = runtime.getCell<number>(
1483+
space,
1484+
"sample test first cell",
1485+
undefined,
1486+
tx,
1487+
);
1488+
firstCell.set(10);
1489+
1490+
const secondCell = runtime.getCell<number>(
1491+
space,
1492+
"sample test second cell",
1493+
undefined,
1494+
tx,
1495+
);
1496+
secondCell.set(5);
1497+
1498+
const resultCell = runtime.getCell<{ result: number }>(
1499+
space,
1500+
"should support non-reactive reads with sample()",
1501+
{
1502+
type: "object",
1503+
properties: { result: { type: "number" } },
1504+
} as const satisfies JSONSchema,
1505+
tx,
1506+
);
1507+
1508+
const result = runtime.run(tx, sampleRecipe, {
1509+
first: firstCell,
1510+
second: secondCell,
1511+
}, resultCell);
1512+
tx.commit();
1513+
tx = runtime.edit();
1514+
1515+
await runtime.idle();
1516+
1517+
// Verify initial result: 10 + 5 = 15
1518+
expect(result.get()).toMatchObject({ result: 15 });
1519+
expect(liftRunCount).toBe(1);
1520+
1521+
// Update the second cell (read with sample(), so non-reactive)
1522+
secondCell.withTx(tx).send(20);
1523+
tx.commit();
1524+
tx = runtime.edit();
1525+
1526+
await runtime.idle();
1527+
1528+
// The lift should NOT have re-run because sample() is non-reactive
1529+
expect(liftRunCount).toBe(1);
1530+
// Result should still be 15 (not updated)
1531+
expect(result.get()).toMatchObject({ result: 15 });
1532+
1533+
// Now update the first cell (read reactively via the normal get())
1534+
firstCell.withTx(tx).send(100);
1535+
tx.commit();
1536+
tx = runtime.edit();
1537+
1538+
await runtime.idle();
1539+
1540+
// The lift should have re-run now
1541+
expect(liftRunCount).toBe(2);
1542+
// Result should reflect both new values: 100 + 20 = 120
1543+
// (the second cell's new value is picked up because the lift re-ran)
1544+
expect(result.get()).toMatchObject({ result: 120 });
1545+
});
14461546
});

0 commit comments

Comments
 (0)