From 02f501d09e2572b14d2c82888ae42fa6093183c5 Mon Sep 17 00:00:00 2001 From: Bernhard Seefeld Date: Sat, 29 Nov 2025 14:19:50 -0800 Subject: [PATCH] feat(runner): Add sample() method to Cell for non-reactive reads (#2175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/api/index.ts | 6 + packages/runner/src/cell.ts | 27 ++++ packages/runner/src/schema.ts | 18 ++- .../storage/extended-storage-transaction.ts | 120 ++++++++++++++++++ packages/runner/src/storage/interface.ts | 3 + packages/runner/test/recipes.test.ts | 100 +++++++++++++++ 6 files changed, 264 insertions(+), 10 deletions(-) diff --git a/packages/api/index.ts b/packages/api/index.ts index a53022ec93..e6f0fe6ed4 100644 --- a/packages/api/index.ts +++ b/packages/api/index.ts @@ -76,6 +76,12 @@ export interface IAnyCell { */ export interface IReadable { get(): Readonly; + /** + * 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; } /** diff --git a/packages/runner/src/cell.ts b/packages/runner/src/cell.ts index d5f95c0c3a..76a1917730 100644 --- a/packages/runner/src/cell.ts +++ b/packages/runner/src/cell.ts @@ -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"; @@ -206,6 +207,7 @@ export type { MemorySpace } from "@commontools/memory/interface"; const cellMethods = new Set>([ "get", + "sample", "set", "send", "update", @@ -519,6 +521,31 @@ export class CellImpl implements ICell, IStreamable { 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 { + 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, onCommit?: (tx: IExtendedStorageTransaction) => void, diff --git a/packages/runner/src/schema.ts b/packages/runner/src/schema.ts index 5ce0dd7c8b..21cd45c33a 100644 --- a/packages/runner/src/schema.ts +++ b/packages/runner/src/schema.ts @@ -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, @@ -141,7 +142,7 @@ export function processDefaultValue( schema: mergeDefaults(resolvedSchema, defaultValue), rootSchema, }, - tx, + getTransactionForChildCells(tx), ); } } @@ -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); @@ -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 @@ -847,12 +850,7 @@ export function validateAndTransform( ? { ...(value as Record) } : [...(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; diff --git a/packages/runner/src/storage/extended-storage-transaction.ts b/packages/runner/src/storage/extended-storage-transaction.ts index 2b14cbc61b..cd369f18ac 100644 --- a/packages/runner/src/storage/extended-storage-transaction.ts +++ b/packages/runner/src/storage/extended-storage-transaction.ts @@ -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 { + return this.wrapped.reader(space); + } + + private addNonReactiveMeta(options?: IReadOptions): IReadOptions { + return { + ...options, + meta: { ...options?.meta, ...ignoreReadForScheduling }, + }; + } + + read( + address: IMemorySpaceAddress, + options?: IReadOptions, + ): Result { + 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 { + return this.wrapped.writer(space); + } + + write( + address: IMemorySpaceAddress, + value: JSONValue | undefined, + ): Result { + 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 { + return this.wrapped.abort(reason); + } + + commit(): Promise> { + 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; +} diff --git a/packages/runner/src/storage/interface.ts b/packages/runner/src/storage/interface.ts index 7125cb2565..564a82848f 100644 --- a/packages/runner/src/storage/interface.ts +++ b/packages/runner/src/storage/interface.ts @@ -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"; diff --git a/packages/runner/test/recipes.test.ts b/packages/runner/test/recipes.test.ts index 9a88784c61..1d62695fed 100644 --- a/packages/runner/test/recipes.test.ts +++ b/packages/runner/test/recipes.test.ts @@ -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( + space, + "sample test first cell", + undefined, + tx, + ); + firstCell.set(10); + + const secondCell = runtime.getCell( + 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 }); + }); });