Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
98b3c31
feat: add HTML dataset attribute support to JSX renderer
Oct 14, 2025
c963046
rough calendar data structure, for view list of dates to be shown, fo…
ellyxir Oct 14, 2025
47bd43e
handle click on UI item and show the date in different UI element
ellyxir Oct 14, 2025
c037817
cross reference and show the list of todos for the clicked date
ellyxir Oct 14, 2025
1d4e212
moved showing a date and its todo to a subrecipe
ellyxir Oct 14, 2025
ee2dde1
fix: improve dataset attribute handling and type safety
seefeldb Oct 14, 2025
be13d58
test: add comprehensive tests for dataset attribute handling
seefeldb Oct 14, 2025
f442651
add item operator, except i think the handler is overly cautious
ellyxir Oct 14, 2025
69c9bd4
Merge remote-tracking branch 'origin/feat/dataset-support' into ellys…
ellyxir Oct 14, 2025
21ee717
can remove items but adding new one first time after breaks
ellyxir Oct 14, 2025
7b360f3
removed prefilled values for arrays, they are empty now
ellyxir Oct 15, 2025
5875004
removed trying to use String()
ellyxir Oct 15, 2025
29a796b
fix(runner): ensure IDs are added to array elements and default values
seefeldb Oct 15, 2025
fcdbddc
make adding IDs recursive, since the calendar use-case sets objects w…
seefeldb Oct 16, 2025
ff1955a
make toCell non-enumerable so that {...obj} doesn't copy them and acc…
seefeldb Oct 16, 2025
6333dd7
Merge remote-tracking branch 'origin/berni/ct-982-fix-defaults-and-cr…
ellyxir Oct 16, 2025
e6b2b5a
fix(runner): inline data URIs before handling write redirects
seefeldb Oct 16, 2025
1f261c7
added test
seefeldb Oct 16, 2025
bc00f23
Merge remote-tracking branch 'origin/fix/inline-data-uri-on-write' in…
ellyxir Oct 16, 2025
2093025
use spread operators
ellyxir Oct 17, 2025
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
140 changes: 109 additions & 31 deletions packages/runner/src/cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ import { diffAndUpdate } from "./data-updating.ts";
import { resolveLink } from "./link-resolution.ts";
import { ignoreReadForScheduling, txToReactivityLog } from "./scheduler.ts";
import { type Cancel, isCancel, useCancelGroup } from "./cancel.ts";
import { validateAndTransform } from "./schema.ts";
import {
processDefaultValue,
resolveSchema,
validateAndTransform,
} from "./schema.ts";
import { toURI } from "./uri-utils.ts";
import {
type LegacyJSONCellLink,
Expand Down Expand Up @@ -448,6 +452,9 @@ export class RegularCell<T> implements Cell<T> {
// retry on conflict.
if (!this.synced) this.sync();

// Looks for arrays and makes sure each object gets its own doc.
newValue = recursivelyAddIDIfNeeded(newValue);
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Oct 17, 2025

Choose a reason for hiding this comment

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

Calling recursivelyAddIDIfNeeded here converts Cell arguments into plain objects before diffAndUpdate runs, so flows like cell.set(otherCell) or push(cell) stop emitting links and instead serialize the cell’s internal fields, breaking link-based updates.

Prompt for AI agents
Address the following comment on packages/runner/src/cell.ts at line 456:

<comment>Calling `recursivelyAddIDIfNeeded` here converts `Cell` arguments into plain objects before `diffAndUpdate` runs, so flows like `cell.set(otherCell)` or `push(cell)` stop emitting links and instead serialize the cell’s internal fields, breaking link-based updates.</comment>

<file context>
@@ -448,6 +452,9 @@ export class RegularCell&lt;T&gt; implements Cell&lt;T&gt; {
     if (!this.synced) this.sync();
 
+    // Looks for arrays and makes sure each object gets its own doc.
+    newValue = recursivelyAddIDIfNeeded(newValue);
+
     // TODO(@ubik2) investigate whether i need to check classified as i walk down my own obj
</file context>
Fix with Cubic


// TODO(@ubik2) investigate whether i need to check classified as i walk down my own obj
diffAndUpdate(
this.runtime,
Expand Down Expand Up @@ -486,36 +493,37 @@ export class RegularCell<T> implements Cell<T> {
const resolvedLink = resolveLink(this.tx, this.link);
const currentValue = this.tx.readValueOrThrow(resolvedLink);

// If there's no current value, initialize based on schema
// If there's no current value, initialize based on schema, even if there is
// no default value.
if (currentValue === undefined) {
if (isObject(this.schema)) {
// Check if schema allows objects
const allowsObject = ContextualFlowControl.isTrueSchema(this.schema) ||
this.schema.type === "object" ||
(Array.isArray(this.schema.type) &&
this.schema.type.includes("object")) ||
(this.schema.anyOf &&
this.schema.anyOf.some((s) =>
typeof s === "object" && s.type === "object"
));

if (!allowsObject) {
throw new Error(
"Cannot update with object value - schema does not allow objects",
);
}
} else if (this.schema === false) {
const resolvedSchema = resolveSchema(this.schema, this.rootSchema);

// TODO(seefeld,ubik2): This should all be moved to schema helpers. This
// just wants to know whether the value could be an object.
const allowsObject = resolvedSchema === undefined ||
ContextualFlowControl.isTrueSchema(resolvedSchema) ||
(isObject(resolvedSchema) &&
(resolvedSchema.type === "object" ||
(Array.isArray(resolvedSchema.type) &&
resolvedSchema.type.includes("object")) ||
(resolvedSchema.anyOf &&
resolvedSchema.anyOf.some((s) =>
typeof s === "object" && s.type === "object"
))));

if (!allowsObject) {
throw new Error(
"Cannot update with object value - schema does not allow objects",
);
}

this.tx.writeValueOrThrow(resolvedLink, {});
}

// Now update each property
for (const [key, value] of Object.entries(values)) {
// Workaround for type checking, since T can be Cell<> and that's fine.
(this.key as any)(key).set(value);
(this.key as any)(key).set(recursivelyAddIDIfNeeded(value));
}
}

Expand Down Expand Up @@ -546,14 +554,6 @@ export class RegularCell<T> implements Cell<T> {
throw new Error("Can't push into non-array value");
}

// If this is an object and it doesn't have an ID, add one.
const valuesToWrite = value.map((val: any) =>
(!isLink(val) && isObject(val) &&
(val as { [ID]?: unknown })[ID] === undefined && getTopFrame())
? { [ID]: getTopFrame()!.generatedIdCounter++, ...val }
: val
);

// If there is no array yet, create it first. We have to do this as a
// separate operation, so that in the next steps [ID] is properly anchored
// in the array.
Expand All @@ -565,8 +565,14 @@ export class RegularCell<T> implements Cell<T> {
[],
cause,
);
array = isObject(this.schema) && Array.isArray(this.schema?.default)
? this.schema.default
const resolvedSchema = resolveSchema(this.schema, this.rootSchema);
array = isObject(resolvedSchema) && Array.isArray(resolvedSchema?.default)
? processDefaultValue(
this.runtime,
this.tx,
this.link,
resolvedSchema.default,
)
: [];
}

Expand All @@ -575,7 +581,7 @@ export class RegularCell<T> implements Cell<T> {
this.runtime,
this.tx,
resolvedLink,
[...array, ...valuesToWrite],
recursivelyAddIDIfNeeded([...array, ...value]),
cause,
);
}
Expand Down Expand Up @@ -877,6 +883,78 @@ function subscribeToReferencedDocs<T>(
};
}

/**
* Recursively adds IDs elements in arrays, unless they are already a link.
*
* This ensures that mutable arrays only consist of links to documents, at least
* when written to only via .set, .update and .push above.
*
* TODO(seefeld): When an array has default entries and is rewritten as [...old,
* new], this will still break, because the previous entries will point back to
* the array itself instead of being new entries.
*
* @param value - The value to add IDs to.
* @returns The value with IDs added.
*/
function recursivelyAddIDIfNeeded<T>(
value: T,
seen: Map<unknown, unknown> = new Map(),
): T {
// Can't add IDs without top frame.
if (!getTopFrame()) return value;

// Already a link, no need to add IDs. Not a record, no need to add IDs.
if (!isRecord(value) || isLink(value)) return value;

// Already seen, return previously annotated result.
if (seen.has(value)) return seen.get(value) as T;

if (Array.isArray(value)) {
const result: unknown[] = [];

// Set before traversing, otherwise we'll infinite recurse.
seen.set(value, result);

result.push(...value.map((v) => {
const value = recursivelyAddIDIfNeeded(v, seen);
// For objects on arrays only: Add ID if not already present.
if (
isObject(value) && !isLink(value) && !(ID in value)
) {
return { [ID]: getTopFrame()!.generatedIdCounter++, ...value };
} else {
return value;
}
}));
return result as T;
} else {
const result: Record<string, unknown> = {};

// Set before traversing, otherwise we'll infinite recurse.
seen.set(value, result);

Object.entries(value).forEach(([key, v]) => {
result[key] = recursivelyAddIDIfNeeded(v, seen);
});

// Copy supported symbols from original value.
if (ID in value) {
(result as { [ID]: unknown })[ID] = value[ID];
}
if (ID_FIELD in value) {
(result as { [ID_FIELD]: unknown })[ID_FIELD] = value[ID_FIELD];
}

return result as T;
}
}

/**
* Converts cells and objects that can be turned to cells to links.
*
* @param value - The value to convert.
* @returns The converted value.
*/
export function convertCellsToLinks(
value: readonly any[] | Record<string, any> | any,
path: string[] = [],
Expand Down
146 changes: 76 additions & 70 deletions packages/runner/src/data-updating.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import {
import { type IRuntime } from "./runtime.ts";
import { toURI } from "./uri-utils.ts";

const diffLogger = getLogger("normalizeAndDiff", {
enabled: false,
level: "debug",
});

/**
* Traverses newValue and updates `current` and any relevant linked documents.
*
Expand Down Expand Up @@ -86,34 +91,6 @@ type ChangeSet = {
* @param context - The context of the change.
* @returns An array of changes that should be written.
*/
const diffLogger = getLogger("normalizeAndDiff", {
enabled: false,
level: "debug",
});

/**
* Returns true if `target` is the immediate parent of `base` in the same document.
*
* Example:
* - base.path = ["internal", "__#1", "next"]
* - target.path = ["internal", "__#1"]
*
* This is used to decide when to collapse a self/parent link that would create
* a tight self-loop (e.g., obj.next -> obj) while allowing references to
* higher ancestors (like an item's `items` pointing to its containing array).
*/
function isImmediateParent(
target: NormalizedFullLink,
base: NormalizedFullLink,
): boolean {
return (
target.id === base.id &&
target.space === base.space &&
target.path.length === base.path.length - 1 &&
target.path.every((seg, i) => seg === base.path[i])
);
}

export function normalizeAndDiff(
runtime: IRuntime,
tx: IExtendedStorageTransaction,
Expand Down Expand Up @@ -232,6 +209,54 @@ export function normalizeAndDiff(
newValue = newValue.getAsLink();
}

// Check for links that are data: URIs and inline them, by calling
// normalizeAndDiff on the contents of the link.
if (isLink(newValue)) {
const parsedLink = parseLink(newValue, link);
if (parsedLink.id.startsWith("data:")) {
diffLogger.debug(() =>
`[BRANCH_CELL_LINK] Data link detected, treating as contents at path=${pathStr}`
);
// Use the tx code to make sure we read it the same way
let dataValue: any = tx.readValueOrThrow({
...parsedLink,
path: [],
}, options);
const path = [...parsedLink.path];
// If there is a link on the way to `path`, follow it, appending remaining
// path to the target link.
for (;;) {
if (isAnyCellLink(dataValue)) {
const dataLink = parseLink(dataValue, parsedLink);
dataValue = createSigilLinkFromParsedLink({
...dataLink,
path: [...dataLink.path, ...path],
});
break;
}
if (path.length > 0) {
if (isRecord(dataValue)) {
dataValue = dataValue[path.shift()!];
} else {
dataValue = undefined;
break;
}
} else {
break;
}
}
return normalizeAndDiff(
runtime,
tx,
link,
dataValue,
context,
options,
seen,
);
}
}

// If we're about to create a reference to ourselves, no-op
if (areMaybeLinkAndNormalizedLinkSame(newValue, link)) {
diffLogger.debug(() =>
Expand Down Expand Up @@ -318,48 +343,6 @@ export function normalizeAndDiff(
seen,
);
}
if (parsedLink.id.startsWith("data:")) {
diffLogger.debug(() =>
`[BRANCH_CELL_LINK] Data link detected, treating as contents at path=${pathStr}`
);
// If there is a data link treat it as writing it's contents instead.

// Use the tx code to make sure we read it the same way
let dataValue: any = tx.readValueOrThrow({
...parsedLink,
path: [],
}, options);
const path = [...parsedLink.path];
for (;;) {
if (isAnyCellLink(dataValue)) {
const dataLink = parseLink(dataValue, parsedLink);
dataValue = createSigilLinkFromParsedLink({
...dataLink,
path: [...dataLink.path, ...path],
});
break;
}
if (path.length > 0) {
if (isRecord(dataValue)) {
dataValue = dataValue[path.shift()!];
} else {
dataValue = undefined;
break;
}
} else {
break;
}
}
return normalizeAndDiff(
runtime,
tx,
link,
dataValue,
context,
options,
seen,
);
}
if (
isAnyCellLink(currentValue) &&
areLinksSame(newValue, currentValue, link)
Expand Down Expand Up @@ -634,3 +617,26 @@ export function addCommonIDfromObjectID(

traverse(obj);
}

/**
* Returns true if `target` is the immediate parent of `base` in the same document.
*
* Example:
* - base.path = ["internal", "__#1", "next"]
* - target.path = ["internal", "__#1"]
*
* This is used to decide when to collapse a self/parent link that would create
* a tight self-loop (e.g., obj.next -> obj) while allowing references to
* higher ancestors (like an item's `items` pointing to its containing array).
*/
function isImmediateParent(
target: NormalizedFullLink,
base: NormalizedFullLink,
): boolean {
return (
target.id === base.id &&
target.space === base.space &&
target.path.length === base.path.length - 1 &&
target.path.every((seg, i) => seg === base.path[i])
);
}
Loading