Skip to content
Draft
Show file tree
Hide file tree
Changes from 12 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
35 changes: 30 additions & 5 deletions packages/html/src/render.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isObject } from "@commontools/utils/types";
import { isObject, isRecord } from "@commontools/utils/types";
import {
type Cancel,
type Cell,
Expand Down Expand Up @@ -322,10 +322,23 @@ const listen = (
};

const setProp = <T>(target: T, key: string, value: unknown) => {
// @ts-ignore - we've validated these via runtime checks
if (target[key] !== value) {
// @ts-ignore - we've validated these via runtime checks
target[key] = value;
// Handle data-* attributes specially - they need to be set as HTML attributes
// to populate the dataset property correctly
if (key.startsWith("data-") && target instanceof Element) {
// If value is null or undefined, remove the attribute
if (value == null) {
if (target.hasAttribute(key)) {
target.removeAttribute(key);
}
} else {
const currentValue = target.getAttribute(key);
const newValue = String(value);
if (currentValue !== newValue) {
target.setAttribute(key, newValue);
}
}
} else if (target[key as keyof T] !== value) {
target[key as keyof T] = value as T[keyof T];
}
};

Expand Down Expand Up @@ -415,6 +428,18 @@ export function serializableEvent<T>(event: Event): T {
);
}

// Copy dataset as a plain object for serialization
if (isObject(target) && "dataset" in target && isRecord(target.dataset)) {
const dataset: Record<string, string> = {};
for (const key in target.dataset) {
// String() to normalize, just in case
dataset[key] = String(target.dataset[key]);
}
if (Object.keys(dataset).length > 0) {
targetObject.dataset = dataset;
}
}

if (Object.keys(targetObject).length > 0) eventObject.target = targetObject;

if ((event as CustomEvent).detail !== undefined) {
Expand Down
60 changes: 59 additions & 1 deletion packages/html/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@ function renderOptionsFromDoc(document: globalThis.Document): RenderOptions {
key: string,
value: unknown,
) {
const el = element as any;

// Handle data-* attributes specially - they need to be set as HTML attributes
// to populate the dataset property correctly
if (key.startsWith("data-") && el.attribs) {
// If value is null or undefined, remove the attribute
if (value == null) {
if (key in el.attribs) {
delete el.attribs[key];
}
} else {
el.attribs[key] = String(value);
}
return;
}

// For non-data attributes, use the existing logic
let attrValue;
if (typeof value === "string") {
attrValue = value;
Expand All @@ -26,7 +43,6 @@ function renderOptionsFromDoc(document: globalThis.Document): RenderOptions {
} else {
attrValue = `${value}`;
}
const el = element as domhandler.Element;
if (!el.attribs[key]) {
el.attribs[key] = attrValue;
}
Expand Down Expand Up @@ -94,6 +110,48 @@ export class MockDoc {
}
},
},
setAttribute: {
value(attrName: string, value: string) {
if (this && (this as any).attribs) {
// @ts-ignore: domhandler.Element has `attribs`
(this as Element).attribs[attrName] = value;
}
},
},
hasAttribute: {
value(attrName: string) {
if (this && (this as any).attribs) {
// @ts-ignore: domhandler.Element has `attribs`
return attrName in (this as Element).attribs;
}
return false;
},
},
removeAttribute: {
value(attrName: string) {
if (this && (this as any).attribs) {
// @ts-ignore: domhandler.Element has `attribs`
delete (this as Element).attribs[attrName];
}
},
},
dataset: {
Copy link
Contributor

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

Choose a reason for hiding this comment

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

The new dataset getter returns a fresh object without wiring assignments back to attribs, so writes like el.dataset.foo = "bar" silently fail to create the corresponding data-foo attribute. Please make dataset behave like DOMStringMap and sync property writes to attributes.

Prompt for AI agents
Address the following comment on packages/html/src/utils.ts at line 138:

<comment>The new dataset getter returns a fresh object without wiring assignments back to attribs, so writes like `el.dataset.foo = &quot;bar&quot;` silently fail to create the corresponding `data-foo` attribute. Please make dataset behave like DOMStringMap and sync property writes to attributes.</comment>

<file context>
@@ -94,6 +110,48 @@ export class MockDoc {
+          }
+        },
+      },
+      dataset: {
+        get() {
+          const el = this as any;
</file context>
Fix with Cubic

get() {
const el = this as any;
if (!el.attribs) return {};
const dataset: Record<string, string> = {};
for (const key in el.attribs) {
if (key.startsWith("data-")) {
const dataKey = key.slice(5).replace(
/-([a-z])/g,
(_, char) => char.toUpperCase(),
);
dataset[dataKey] = el.attribs[key];
}
}
return dataset;
},
},
};

// Extend `Document` with element creation methods
Expand Down
170 changes: 170 additions & 0 deletions packages/html/test/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,4 +380,174 @@ describe("serializableEvent", () => {
"Should not include id on target",
);
});

it("serializes an event with target.dataset", () => {
const { document } = mock;
const div = document.createElement("div");
div.setAttribute("data-id", "123");
div.setAttribute("data-name", "test");
const event = new Event("click");
Object.defineProperty(event, "target", { value: div });
const result = serializableEvent(event) as object;
assert.matchObject(result, {
type: "click",
target: {
dataset: {
id: "123",
name: "test",
},
},
});
assert.equal(
isPlainSerializableObject(result),
true,
"Result should be a plain serializable object",
);
});
});

describe("dataset attributes", () => {
it("sets data-* attributes using setAttribute", () => {
const { renderOptions, document } = mock;
const vnode = {
type: "vnode" as const,
name: "div",
props: {
"data-id": "123",
"data-name": "test",
},
children: [],
};
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, vnode, renderOptions);
const div = parent.getElementsByTagName("div")[0]!;
assert.equal(div.getAttribute("data-id"), "123");
assert.equal(div.getAttribute("data-name"), "test");
// @ts-ignore: dataset exists on Element in real DOM
assert.equal(div.dataset.id, "123");
// @ts-ignore: dataset exists on Element in real DOM
assert.equal(div.dataset.name, "test");
cancel();
});

it("removes data-* attributes when value is null", () => {
const { renderOptions, document } = mock;
const parent = document.getElementById("root")!;

// First render with data attribute
const vnode1 = {
type: "vnode" as const,
name: "div",
props: {
"data-id": "123",
},
children: [],
};
const cancel1 = renderImpl(parent, vnode1, renderOptions);
const div = parent.getElementsByTagName("div")[0]!;
assert.equal(div.getAttribute("data-id"), "123");
cancel1();

// Re-render with null value
const vnode2 = {
type: "vnode" as const,
name: "div",
props: {
"data-id": null,
},
children: [],
};
const cancel2 = renderImpl(parent, vnode2, renderOptions);
const div2 = parent.getElementsByTagName("div")[0]!;
assert.equal(div2.hasAttribute("data-id"), false);
cancel2();
});

it("removes data-* attributes when value is undefined", () => {
const { renderOptions, document } = mock;
const parent = document.getElementById("root")!;

// First render with data attribute
const vnode1 = {
type: "vnode" as const,
name: "div",
props: {
"data-id": "123",
},
children: [],
};
const cancel1 = renderImpl(parent, vnode1, renderOptions);
const div = parent.getElementsByTagName("div")[0]!;
assert.equal(div.getAttribute("data-id"), "123");
cancel1();

// Re-render with undefined value
// @ts-ignore: Testing undefined handling even though it's not in Props type
const vnode2 = {
type: "vnode" as const,
name: "div",
props: {
"data-id": undefined,
},
children: [],
} as VNode;
const cancel2 = renderImpl(parent, vnode2, renderOptions);
const div2 = parent.getElementsByTagName("div")[0]!;
assert.equal(div2.hasAttribute("data-id"), false);
cancel2();
});

it("converts non-string values to strings for data-* attributes", () => {
const { renderOptions, document } = mock;
const vnode = {
type: "vnode" as const,
name: "div",
props: {
"data-count": 42,
"data-enabled": true,
"data-items": ["a", "b", "c"],
},
children: [],
};
const parent = document.getElementById("root")!;
const cancel = renderImpl(parent, vnode, renderOptions);
const div = parent.getElementsByTagName("div")[0]!;
assert.equal(div.getAttribute("data-count"), "42");
assert.equal(div.getAttribute("data-enabled"), "true");
assert.equal(div.getAttribute("data-items"), "a,b,c");
cancel();
});

it("updates data-* attributes when value changes", () => {
const { renderOptions, document } = mock;
const parent = document.getElementById("root")!;

// First render
const vnode1 = {
type: "vnode" as const,
name: "div",
props: {
"data-id": "123",
},
children: [],
};
const cancel1 = renderImpl(parent, vnode1, renderOptions);
const div = parent.getElementsByTagName("div")[0]!;
assert.equal(div.getAttribute("data-id"), "123");
cancel1();

// Update with new value
const vnode2 = {
type: "vnode" as const,
name: "div",
props: {
"data-id": "456",
},
children: [],
};
const cancel2 = renderImpl(parent, vnode2, renderOptions);
const div2 = parent.getElementsByTagName("div")[0]!;
assert.equal(div2.getAttribute("data-id"), "456");
cancel2();
});
});
Loading
Loading