diff --git a/packages/core/src/parsers/hfIds.test.ts b/packages/core/src/parsers/hfIds.test.ts
index f4125be8f..6001afe08 100644
--- a/packages/core/src/parsers/hfIds.test.ts
+++ b/packages/core/src/parsers/hfIds.test.ts
@@ -77,6 +77,24 @@ describe("ensureHfIds", () => {
expect(a).toMatch(/^hf-[a-z0-9]{4}$/);
expect(b).toMatch(/^hf-[a-z0-9]{4}$/);
});
+
+ // Post-persist stability: once data-hf-id is written back to source, edits
+ // don't drift the id because the attribute is already present and pinned.
+ it("pinned id survives text edit after first persist", () => {
+ const raw = `
original text
`;
+ const persisted = ensureHfIds(raw); // simulates write-back on first serve
+ const [originalId] = ids(persisted);
+ const edited = persisted.replace("original text", "edited text");
+ expect(ids(ensureHfIds(edited))).toContain(originalId);
+ });
+
+ it("pinned id survives attribute edit after first persist", () => {
+ const raw = `text
`;
+ const persisted = ensureHfIds(raw); // simulates write-back on first serve
+ const [originalId] = ids(persisted);
+ const edited = persisted.replace('class="old"', 'class="new"');
+ expect(ids(ensureHfIds(edited))).toContain(originalId);
+ });
});
// Lock the edit-lifecycle behavior. These pin BOTH the guarantee that holds
diff --git a/packages/core/src/parsers/hfIds.ts b/packages/core/src/parsers/hfIds.ts
index 6f005eeb7..7b1f9cff4 100644
--- a/packages/core/src/parsers/hfIds.ts
+++ b/packages/core/src/parsers/hfIds.ts
@@ -54,6 +54,19 @@ function contentKey(el: Element): string {
return `${el.tagName.toLowerCase()}|${attrs}|${ownText(el)}`;
}
+/**
+ * Collision tiebreak for byte-identical siblings: document-order dup counter
+ * (`hash(key#N)`). This IS order-dependent — two identical ``
+ * get different ids based on which comes first in the DOM. This is unavoidable:
+ * unique ids for byte-identical elements require a positional signal.
+ *
+ * Why this is safe in practice: once `ensureHfIds` write-back persists
+ * `data-hf-id` to source the attribute is physically bound to its element.
+ * Reordering identical siblings carries the attribute along → zero
+ * order-dependence post-persist. `ensureHfIds` skips pinned elements
+ * (`if (el.getAttribute("data-hf-id")) continue`), so normal operation
+ * never re-exposes the ordering after first persist.
+ */
export function mintHfId(el: Element, assigned: Set): string {
const key = contentKey(el);
let id = toHfId(fnv1a(key));
diff --git a/packages/core/src/studio-api/helpers/hfIdPersist.test.ts b/packages/core/src/studio-api/helpers/hfIdPersist.test.ts
new file mode 100644
index 000000000..e0e0ab154
--- /dev/null
+++ b/packages/core/src/studio-api/helpers/hfIdPersist.test.ts
@@ -0,0 +1,69 @@
+import { describe, it, expect, afterEach } from "vitest";
+import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { normalizeHfIds, persistHfIdsIfNeeded } from "./hfIdPersist.js";
+
+describe("normalizeHfIds", () => {
+ it("marks changed=true and adds data-hf-id to all body elements when untagged", () => {
+ const raw = ``;
+ const { html, changed } = normalizeHfIds(raw);
+ expect(changed).toBe(true);
+ expect(html).toContain('data-hf-id="hf-');
+ const matches = html.match(/data-hf-id="hf-[a-z0-9]{4}"/g);
+ expect(matches?.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("marks changed=false for already-normalized HTML (idempotent round-trip)", () => {
+ const raw = ``;
+ const first = normalizeHfIds(raw).html;
+ const { html, changed } = normalizeHfIds(first);
+ expect(changed).toBe(false);
+ expect(html).toBe(first);
+ });
+});
+
+describe("persistHfIdsIfNeeded", () => {
+ const tmpDirs: string[] = [];
+
+ afterEach(() => {
+ for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
+ tmpDirs.length = 0;
+ });
+
+ function tmpFile(content: string): string {
+ const dir = mkdtempSync(join(tmpdir(), "hfid-test-"));
+ tmpDirs.push(dir);
+ const file = join(dir, "index.html");
+ writeFileSync(file, content, "utf-8");
+ return file;
+ }
+
+ it("writes data-hf-id to disk when source is untagged", () => {
+ const raw = `hello
`;
+ const file = tmpFile(raw);
+ const returned = persistHfIdsIfNeeded(file, raw);
+ expect(returned).toContain('data-hf-id="hf-');
+ const onDisk = readFileSync(file, "utf-8");
+ expect(onDisk).toContain('data-hf-id="hf-');
+ expect(onDisk).toBe(returned);
+ });
+
+ it("does not rewrite disk when source is already tagged", () => {
+ const raw = `hello
`;
+ const file = tmpFile(raw);
+ const tagged = persistHfIdsIfNeeded(file, raw);
+ const diskAfterFirst = readFileSync(file, "utf-8");
+ const returned2 = persistHfIdsIfNeeded(file, tagged);
+ expect(returned2).toBe(tagged);
+ expect(readFileSync(file, "utf-8")).toBe(diskAfterFirst);
+ });
+
+ it("returned id matches id written to disk (serve-time == persist-time invariant)", () => {
+ const raw = `text`;
+ const file = tmpFile(raw);
+ const result = persistHfIdsIfNeeded(file, raw);
+ const onDisk = readFileSync(file, "utf-8");
+ expect(result).toBe(onDisk);
+ });
+});
diff --git a/packages/core/src/studio-api/helpers/hfIdPersist.ts b/packages/core/src/studio-api/helpers/hfIdPersist.ts
new file mode 100644
index 000000000..8ec6bfdc4
--- /dev/null
+++ b/packages/core/src/studio-api/helpers/hfIdPersist.ts
@@ -0,0 +1,21 @@
+import { ensureHfIds } from "../../parsers/hfIds.js";
+import { writeFileSync } from "node:fs";
+
+export { ensureHfIds };
+
+export function normalizeHfIds(html: string): { html: string; changed: boolean } {
+ const normalized = ensureHfIds(html);
+ return { html: normalized, changed: normalized !== html };
+}
+
+export function persistHfIdsIfNeeded(filePath: string, html: string): string {
+ const { html: normalized, changed } = normalizeHfIds(html);
+ if (changed) {
+ try {
+ writeFileSync(filePath, normalized, "utf-8");
+ } catch {
+ // non-fatal — serve with ids even if persist fails
+ }
+ }
+ return normalized;
+}
diff --git a/packages/core/src/studio-api/routes/preview.test.ts b/packages/core/src/studio-api/routes/preview.test.ts
index 54cd0a6c4..c2c6ca5b2 100644
--- a/packages/core/src/studio-api/routes/preview.test.ts
+++ b/packages/core/src/studio-api/routes/preview.test.ts
@@ -328,3 +328,36 @@ describe("registerPreviewRoutes", () => {
expect(signature).toMatch(/^[a-f0-9]{24}$/);
});
});
+
+describe("hf-id surfacing in preview route", () => {
+ it("serves HTML with data-hf-id on body elements (R7 write-back)", async () => {
+ const projectDir = createProjectDir();
+ writeFileSync(
+ join(projectDir, "index.html"),
+ ``,
+ );
+ const app = new Hono();
+ registerPreviewRoutes(app, createAdapter(projectDir));
+ const res = await app.request("http://localhost/projects/demo/preview");
+ expect(res.status).toBe(200);
+ const html = await res.text();
+ const ids = html.match(/data-hf-id="hf-[a-z0-9]{4}"/g);
+ // div and p both tagged
+ expect(ids?.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("writes data-hf-id back to disk on first serve", async () => {
+ const { readFileSync } = await import("node:fs");
+ const projectDir = createProjectDir();
+ const indexPath = join(projectDir, "index.html");
+ writeFileSync(
+ indexPath,
+ `hello
`,
+ );
+ const app = new Hono();
+ registerPreviewRoutes(app, createAdapter(projectDir));
+ await app.request("http://localhost/projects/demo/preview");
+ const onDisk = readFileSync(indexPath, "utf-8");
+ expect(onDisk).toContain('data-hf-id="hf-');
+ });
+});
diff --git a/packages/core/src/studio-api/routes/preview.ts b/packages/core/src/studio-api/routes/preview.ts
index 30da87ba6..ad74ec2f8 100644
--- a/packages/core/src/studio-api/routes/preview.ts
+++ b/packages/core/src/studio-api/routes/preview.ts
@@ -11,6 +11,7 @@ import {
createStudioMotionRenderBodyScript,
STUDIO_MOTION_PATH,
} from "../helpers/studioMotionRenderScript.js";
+import { ensureHfIds, persistHfIdsIfNeeded } from "../helpers/hfIdPersist.js";
const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
const GSAP_CDN_VERSION = "3.15.0";
@@ -205,14 +206,19 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
return new Response(null, { status: 304, headers: previewCacheHeaders(etag) });
}
+ // Normalize + persist data-hf-id to disk before bundle reads it. Idempotent.
+ const diskMain = resolveProjectMainHtml(project.dir, project.id);
+ const normalizedDisk = diskMain
+ ? persistHfIdsIfNeeded(join(project.dir, diskMain.compositionPath), diskMain.html)
+ : null;
+
try {
let bundled = await adapter.bundle(project.dir);
let mainCompositionPath = "index.html";
if (!bundled) {
- const main = resolveProjectMainHtml(project.dir, project.id);
- if (!main) return c.text("not found", 404);
- bundled = main.html;
- mainCompositionPath = main.compositionPath;
+ if (!diskMain || normalizedDisk === null) return c.text("not found", 404);
+ bundled = normalizedDisk;
+ mainCompositionPath = diskMain.compositionPath;
}
// Inject runtime if not already present (check URL pattern and bundler attribute)
@@ -233,21 +239,27 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
}
bundled = injectStudioPreviewAugmentations(
- await transformPreviewHtml(bundled, adapter, project, mainCompositionPath),
+ ensureHfIds(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)),
adapter,
project.dir,
mainCompositionPath,
);
return c.html(bundled, 200, previewCacheHeaders(etag));
} catch {
- const main = resolveProjectMainHtml(project.dir, project.id);
- if (main) {
+ if (diskMain && normalizedDisk !== null) {
return c.html(
injectStudioPreviewAugmentations(
- await transformPreviewHtml(main.html, adapter, project, main.compositionPath),
+ ensureHfIds(
+ await transformPreviewHtml(
+ normalizedDisk,
+ adapter,
+ project,
+ diskMain.compositionPath,
+ ),
+ ),
adapter,
project.dir,
- main.compositionPath,
+ diskMain.compositionPath,
),
200,
previewCacheHeaders(etag),
@@ -284,7 +296,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
const baseHref = `/api/projects/${project.id}/preview/`;
let html = buildSubCompositionHtml(project.dir, compPath, adapter.runtimeUrl, baseHref);
if (!html) return c.text("not found", 404);
- html = await transformPreviewHtml(html, adapter, project, compPath);
+ html = ensureHfIds(await transformPreviewHtml(html, adapter, project, compPath));
return c.html(
injectStudioPreviewAugmentations(html, adapter, project.dir, compPath),
200,