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
6 changes: 6 additions & 0 deletions packages/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ export interface IAnyCell<T> {
*/
export interface IReadable<T> {
get(): Readonly<T>;
/**
* Read the cell's current value without creating a reactive dependency.
* Unlike `get()`, calling `sample()` inside a lift won't cause the lift
* to re-run when this cell's value changes.
*/
sample(): Readonly<T>;
}

/**
Expand Down
27 changes: 27 additions & 0 deletions packages/runner/src/cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import type {
IExtendedStorageTransaction,
IReadOptions,
} from "./storage/interface.ts";
import { NonReactiveTransaction } from "./storage/extended-storage-transaction.ts";
import { fromURI } from "./uri-utils.ts";
import { ContextualFlowControl } from "./cfc.ts";

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

const cellMethods = new Set<keyof ICell<unknown>>([
"get",
"sample",
"set",
"send",
"update",
Expand Down Expand Up @@ -519,6 +521,31 @@ export class CellImpl<T> implements ICell<T>, IStreamable<T> {
return validateAndTransform(this.runtime, this.tx, this.link, this.synced);
}

/**
* Read the cell's current value without creating a reactive dependency.
* Unlike `get()`, calling `sample()` inside a handler won't cause the handler
* to re-run when this cell's value changes.
*
* Use this when you need to read a value but don't want changes to that value
* to trigger re-execution of the current reactive context.
*/
sample(): Readonly<T> {
if (!this.synced) this.sync(); // No await, just kicking this off

// Wrap the transaction with NonReactiveTransaction to make all reads
// non-reactive. Child cells created during validateAndTransform will
// use the original transaction (via getTransactionForChildCells).
const readTx = this.runtime.readTx(this.tx);
const nonReactiveTx = new NonReactiveTransaction(readTx);

return validateAndTransform(
this.runtime,
nonReactiveTx,
this.link,
this.synced,
);
}

set(
newValue: AnyCellWrapping<T> | T,
onCommit?: (tx: IExtendedStorageTransaction) => void,
Expand Down
18 changes: 8 additions & 10 deletions packages/runner/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { type JSONSchema, type JSONValue } from "./builder/types.ts";
import { createCell, isCell } from "./cell.ts";
import { readMaybeLink, resolveLink } from "./link-resolution.ts";
import { type IExtendedStorageTransaction } from "./storage/interface.ts";
import { getTransactionForChildCells } from "./storage/extended-storage-transaction.ts";
import { type IRuntime } from "./runtime.ts";
import {
createDataCellURI,
Expand Down Expand Up @@ -141,7 +142,7 @@ export function processDefaultValue(
schema: mergeDefaults(resolvedSchema, defaultValue),
rootSchema,
},
tx,
getTransactionForChildCells(tx),
);
}
}
Expand Down Expand Up @@ -314,7 +315,9 @@ function annotateWithBackToCellSymbols(
) {
// Non-enumerable, so that {...obj} won't copy these symbols
Object.defineProperty(value, toCell, {
value: () => createCell(runtime, link, tx),
// Use getTransactionForChildCells so that if this was called from sample(),
// the resulting cell is still reactive
value: () => createCell(runtime, link, getTransactionForChildCells(tx)),
enumerable: false,
});
Object.freeze(value);
Expand Down Expand Up @@ -438,11 +441,11 @@ export function validateAndTransform(
schema: newSchema,
rootSchema,
},
tx,
getTransactionForChildCells(tx),
);
}
}
return createCell(runtime, link, tx);
return createCell(runtime, link, getTransactionForChildCells(tx));
}

// If there is no schema, return as raw data via query result proxy
Expand Down Expand Up @@ -847,12 +850,7 @@ export function validateAndTransform(
? { ...(value as Record<string, unknown>) }
: [...(value as unknown[])];
seen.push([seenKey, cloned]);
return annotateWithBackToCellSymbols(
cloned,
runtime,
link,
tx,
);
return annotateWithBackToCellSymbols(cloned, runtime, link, tx);
} else {
seen.push([seenKey, value]);
return value;
Expand Down
120 changes: 120 additions & 0 deletions packages/runner/src/storage/extended-storage-transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,123 @@ export class ExtendedStorageTransaction implements IExtendedStorageTransaction {
this.commitCallbacks.add(callback);
}
}

/**
* A wrapper around an IExtendedStorageTransaction that adds ignoreReadForScheduling
* meta to all read operations. This makes reads non-reactive - they won't trigger
* re-execution of handlers when the read values change.
*
* Used by Cell.sample() to read values without subscribing to changes.
*/
export class NonReactiveTransaction implements IExtendedStorageTransaction {
constructor(private wrapped: IExtendedStorageTransaction) {}

/**
* Get the transaction to use for creating child cells.
* Child cells should be reactive, so this returns the unwrapped transaction.
*/
getTransactionForChildCells(): IExtendedStorageTransaction {
return this.wrapped;
}

get tx(): IStorageTransaction {
return this.wrapped.tx;
}

get journal(): ITransactionJournal {
return this.wrapped.journal;
}

status(): StorageTransactionStatus {
return this.wrapped.status();
}

reader(space: MemorySpace): Result<ITransactionReader, ReaderError> {
return this.wrapped.reader(space);
}

private addNonReactiveMeta(options?: IReadOptions): IReadOptions {
return {
...options,
meta: { ...options?.meta, ...ignoreReadForScheduling },
};
}

read(
address: IMemorySpaceAddress,
options?: IReadOptions,
): Result<IAttestation, ReadError> {
return this.wrapped.read(address, this.addNonReactiveMeta(options));
}

readOrThrow(
address: IMemorySpaceAddress,
options?: IReadOptions,
): JSONValue | undefined {
return this.wrapped.readOrThrow(address, this.addNonReactiveMeta(options));
}

readValueOrThrow(
address: IMemorySpaceAddress,
options?: IReadOptions,
): JSONValue | undefined {
return this.wrapped.readValueOrThrow(
address,
this.addNonReactiveMeta(options),
);
}

writer(space: MemorySpace): Result<ITransactionWriter, WriterError> {
return this.wrapped.writer(space);
}

write(
address: IMemorySpaceAddress,
value: JSONValue | undefined,
): Result<IAttestation, WriteError | WriterError> {
return this.wrapped.write(address, value);
}

writeOrThrow(
address: IMemorySpaceAddress,
value: JSONValue | undefined,
): void {
return this.wrapped.writeOrThrow(address, value);
}

writeValueOrThrow(
address: IMemorySpaceAddress,
value: JSONValue | undefined,
): void {
return this.wrapped.writeValueOrThrow(address, value);
}

abort(reason?: unknown): Result<Unit, InactiveTransactionError> {
return this.wrapped.abort(reason);
}

commit(): Promise<Result<Unit, CommitError>> {
return this.wrapped.commit();
}

addCommitCallback(callback: (tx: IExtendedStorageTransaction) => void): void {
return this.wrapped.addCommitCallback(callback);
}
}

/**
* Helper function to get the transaction to use for creating child cells from a
* potentially wrapped NonReactiveTransaction. If the transaction is not wrapped,
* returns it as-is.
*
* Used when creating child cells that should be reactive even when the parent
* read was non-reactive (e.g., in Cell.sample()).
*/
export function getTransactionForChildCells(
tx: IExtendedStorageTransaction | undefined,
): IExtendedStorageTransaction | undefined {
if (tx instanceof NonReactiveTransaction) {
return tx.getTransactionForChildCells();
}
return tx;
}
3 changes: 3 additions & 0 deletions packages/runner/src/storage/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -921,3 +921,6 @@ export interface IAttestation {
readonly address: IMemoryAddress;
readonly value?: JSONValue;
}

// Re-export NonReactiveTransaction from implementation
export { NonReactiveTransaction } from "./extended-storage-transaction.ts";
100 changes: 100 additions & 0 deletions packages/runner/test/recipes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1443,4 +1443,104 @@ describe("Recipe Runner", () => {
const result2 = charmCell.withTx(tx).get();
expect(result2.list.get()).toEqual([{ text: "hello" }]);
});

it("should support non-reactive reads with sample()", async () => {
let liftRunCount = 0;

// A lift that takes two parameters:
// - first: a regular number (reactive)
// - second: a Cell that we'll read with sample() (non-reactive)
const computeWithSample = lift(
// Input schema: first is reactive, second is asCell
{
type: "object",
properties: {
first: { type: "number" },
second: { type: "number", asCell: true },
},
required: ["first", "second"],
} as const satisfies JSONSchema,
// Output schema
{ type: "number" },
// The lift function
({ first, second }) => {
liftRunCount++;
// Use sample() to read the second cell non-reactively
const secondValue = second.sample();
return first + secondValue;
},
);

const sampleRecipe = recipe<{ first: number; second: number }>(
"Sample Recipe",
({ first, second }) => {
return { result: computeWithSample({ first, second }) };
},
);

// Create input cells
const firstCell = runtime.getCell<number>(
space,
"sample test first cell",
undefined,
tx,
);
firstCell.set(10);

const secondCell = runtime.getCell<number>(
space,
"sample test second cell",
undefined,
tx,
);
secondCell.set(5);

const resultCell = runtime.getCell<{ result: number }>(
space,
"should support non-reactive reads with sample()",
{
type: "object",
properties: { result: { type: "number" } },
} as const satisfies JSONSchema,
tx,
);

const result = runtime.run(tx, sampleRecipe, {
first: firstCell,
second: secondCell,
}, resultCell);
tx.commit();
tx = runtime.edit();

await runtime.idle();

// Verify initial result: 10 + 5 = 15
expect(result.get()).toMatchObject({ result: 15 });
expect(liftRunCount).toBe(1);

// Update the second cell (read with sample(), so non-reactive)
secondCell.withTx(tx).send(20);
tx.commit();
tx = runtime.edit();

await runtime.idle();

// The lift should NOT have re-run because sample() is non-reactive
expect(liftRunCount).toBe(1);
// Result should still be 15 (not updated)
expect(result.get()).toMatchObject({ result: 15 });

// Now update the first cell (read reactively via the normal get())
firstCell.withTx(tx).send(100);
tx.commit();
tx = runtime.edit();

await runtime.idle();

// The lift should have re-run now
expect(liftRunCount).toBe(2);
// Result should reflect both new values: 100 + 20 = 120
// (the second cell's new value is picked up because the lift re-ran)
expect(result.get()).toMatchObject({ result: 120 });
});
});
Loading