Skip to content

Commit 3d7d7c0

Browse files
vanceingallsclaude
andauthored
refactor(core,studio): extract draft-marker constants + R7 code-review fixes (Task 4) (#1292)
* refactor(core,studio): extract draft-marker constants to core (R7, Task 4) Create draftMarkers.ts in core with 5 shared CSS custom property names and the gesture DOM attribute. PreviewAdapter imports from draftMarkers.ts instead of hardcoding strings. Adds @hyperframes/core/studio-api/draft-markers export subpath. Studio's manualEditsTypes.ts re-exports the shared constants from core so all existing call sites are unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(core): address R7 code-review findings (C1–C14, P6–P7) - previewAdapter: auto-revert previous gesture in applyDraft (C3); clearDraftProps on commitPreview not just revertDraft (C4); isVisible NaN→visible for JSDOM (P7); remove redundant GestureState.hfId field (C12); remove Array.from (C14); extract clearDraftProps/revertGesture helpers (C5/C6) - hfIdPersist: replace string-equality change detection with attribute count to avoid false-positive writes on single-quoted HTML (C1); re-read disk before write for TOCTOU guard (C7); remove normalizeHfIds wrapper (C11) - preview.ts: remove dead null-check on normalizedDisk after diskMain guard (C9); catch path re-reads disk fresh instead of using stale pre-request snapshot (C8) - hfIds.test.ts: replace tautological second stability test with cross-document content-keyed id stability test (P6) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(core): follow-up R7 review fixes — CSS.escape fallback, invariant docs, new edge-case tests - hfIdPersist: remove ensureHfIds re-export (P2); add JSDoc invariant note; improve TOCTOU comment; pass err to console.warn - preview.ts: split import — ensureHfIds from parsers/hfIds.js (not re-export) - previewAdapter: CSS.escape + inline fallback for non-browser environments; add JSDoc for atTime caller-seek contract; add 0.01 opacity-threshold comment - previewAdapter.test: rename atTime test to clarify adapter-does-not-seek; add nested-hf-root-without-id test; add resize→move prop-leak test; add revertDraft-after-commit no-op test Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(core): bundle-vs-disk id-stability test; comment double ensureHfIds (P3) - preview.test: add "bundle returning untagged HTML gets same ids as disk" test — guards against id divergence when bundler reads a pre-write cache snapshot; content-keyed FNV1a minting ensures served ids == disk ids for same source HTML - preview.ts: comment the second ensureHfIds call explaining it's intentional for adapter-injected elements and idempotent on the no-bundle path (P3 from miguel) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(core): wire-contract comment on mintHfId + fallow suppressions (R7) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1a23938 commit 3d7d7c0

11 files changed

Lines changed: 253 additions & 95 deletions

File tree

packages/core/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@
5050
"import": "./src/studio-api/helpers/studioMotionRenderScript.ts",
5151
"types": "./src/studio-api/helpers/studioMotionRenderScript.ts"
5252
},
53+
"./studio-api/draft-markers": {
54+
"import": "./src/studio-api/helpers/draftMarkers.ts",
55+
"types": "./src/studio-api/helpers/draftMarkers.ts"
56+
},
5357
"./text": {
5458
"import": "./src/text/index.ts",
5559
"types": "./src/text/index.ts"
@@ -121,6 +125,10 @@
121125
"import": "./dist/studio-api/helpers/studioMotionRenderScript.js",
122126
"types": "./dist/studio-api/helpers/studioMotionRenderScript.d.ts"
123127
},
128+
"./studio-api/draft-markers": {
129+
"import": "./dist/studio-api/helpers/draftMarkers.js",
130+
"types": "./dist/studio-api/helpers/draftMarkers.d.ts"
131+
},
124132
"./text": {
125133
"import": "./dist/text/index.js",
126134
"types": "./dist/text/index.d.ts"

packages/core/src/parsers/hfIds.test.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,14 @@ describe("ensureHfIds", () => {
8888
expect(ids(ensureHfIds(edited))).toContain(originalId);
8989
});
9090

91-
it("pinned id survives attribute edit after first persist", () => {
92-
const raw = `<!doctype html><html><body><div class="old">text</div></body></html>`;
93-
const persisted = ensureHfIds(raw); // simulates write-back on first serve
94-
const [originalId] = ids(persisted);
95-
const edited = persisted.replace('class="old"', 'class="new"');
96-
expect(ids(ensureHfIds(edited))).toContain(originalId);
91+
// Hash-based stability (no prior pin): the same element content yields the
92+
// same id regardless of what sibling elements appear in the document.
93+
it("content-keyed minting is stable: same element content → same id in different documents", () => {
94+
const alone = `<!doctype html><html><body><div class="card">hello</div></body></html>`;
95+
const [idAlone] = ids(ensureHfIds(alone));
96+
// The <div class="card">hello</div> appears alongside a new sibling here.
97+
const withSibling = `<!doctype html><html><body><span>prefix</span><div class="card">hello</div></body></html>`;
98+
expect(ids(ensureHfIds(withSibling))).toContain(idAlone);
9799
});
98100
});
99101

packages/core/src/parsers/hfIds.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ function contentKey(el: Element): string {
6767
* (`if (el.getAttribute("data-hf-id")) continue`), so normal operation
6868
* never re-exposes the ordering after first persist.
6969
*/
70+
// WIRE CONTRACT: id minting is content-keyed (FNV1a of innerHTML + tag). R7's
71+
// preview route relies on mintHfId producing identical ids across mint contexts
72+
// (disk-persist pass vs. in-memory bundle pass) — see preview.test.ts
73+
// "bundle returning untagged HTML gets same ids as disk". Any change that adds
74+
// positional, session, or random input to the hash breaks that invariant and
75+
// makes hf- ids diverge between disk and served HTML, silently corrupting
76+
// drag-to-edit targeting.
7077
export function mintHfId(el: Element, assigned: Set<string>): string {
7178
const key = contentKey(el);
7279
let id = toHfId(fnv1a(key));
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Draft-marker constants shared between core's PreviewAdapter and Studio's
3+
* manual-edits code. CSS custom properties written during a drag gesture, plus
4+
* the gesture marker attribute. Exported from @hyperframes/core/studio-api/draft-markers.
5+
*/
6+
export const STUDIO_OFFSET_X_PROP = "--hf-studio-offset-x";
7+
export const STUDIO_OFFSET_Y_PROP = "--hf-studio-offset-y";
8+
export const STUDIO_WIDTH_PROP = "--hf-studio-width";
9+
export const STUDIO_HEIGHT_PROP = "--hf-studio-height";
10+
export const STUDIO_MANUAL_EDIT_GESTURE_ATTR = "data-hf-studio-manual-edit-gesture";

packages/core/src/studio-api/helpers/hfIdPersist.test.ts

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,7 @@ import { describe, it, expect, afterEach } from "vitest";
22
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
5-
import { normalizeHfIds, persistHfIdsIfNeeded } from "./hfIdPersist.js";
6-
7-
describe("normalizeHfIds", () => {
8-
it("marks changed=true and adds data-hf-id to all body elements when untagged", () => {
9-
const raw = `<!doctype html><html><body><div><p>hello</p></div></body></html>`;
10-
const { html, changed } = normalizeHfIds(raw);
11-
expect(changed).toBe(true);
12-
expect(html).toContain('data-hf-id="hf-');
13-
const matches = html.match(/data-hf-id="hf-[a-z0-9]{4}"/g);
14-
expect(matches?.length).toBeGreaterThanOrEqual(2);
15-
});
16-
17-
it("marks changed=false for already-normalized HTML (idempotent round-trip)", () => {
18-
const raw = `<!doctype html><html><body><div><p>hello</p></div></body></html>`;
19-
const first = normalizeHfIds(raw).html;
20-
const { html, changed } = normalizeHfIds(first);
21-
expect(changed).toBe(false);
22-
expect(html).toBe(first);
23-
});
24-
});
5+
import { persistHfIdsIfNeeded } from "./hfIdPersist.js";
256

267
describe("persistHfIdsIfNeeded", () => {
278
const tmpDirs: string[] = [];
@@ -59,11 +40,32 @@ describe("persistHfIdsIfNeeded", () => {
5940
expect(readFileSync(file, "utf-8")).toBe(diskAfterFirst);
6041
});
6142

43+
it("does not rewrite when source is already tagged with non-standard HTML formatting", () => {
44+
// Single-quoted attrs would cause a false-positive write under string-equality
45+
// change detection; count-based detection handles this correctly.
46+
const alreadyTagged = `<!doctype html><html><body><div data-hf-id='hf-ab12'>hello</div></body></html>`;
47+
const file = tmpFile(alreadyTagged);
48+
persistHfIdsIfNeeded(file, alreadyTagged);
49+
expect(readFileSync(file, "utf-8")).toBe(alreadyTagged);
50+
});
51+
6252
it("returned id matches id written to disk (serve-time == persist-time invariant)", () => {
6353
const raw = `<!doctype html><html><body><span>text</span></body></html>`;
6454
const file = tmpFile(raw);
6555
const result = persistHfIdsIfNeeded(file, raw);
6656
const onDisk = readFileSync(file, "utf-8");
6757
expect(result).toBe(onDisk);
6858
});
59+
60+
it("skips write if file was modified concurrently (TOCTOU guard)", () => {
61+
const old = `<!doctype html><html><body><div>original</div></body></html>`;
62+
const newer = `<!doctype html><html><body><div>modified by user</div></body></html>`;
63+
// Disk has newer content — simulates a concurrent save after the server read old.
64+
const file = tmpFile(newer);
65+
const returned = persistHfIdsIfNeeded(file, old);
66+
// Serve-time HTML gets ids based on what we read.
67+
expect(returned).toContain('data-hf-id="hf-');
68+
// Disk must not be overwritten — user's concurrent save is preserved.
69+
expect(readFileSync(file, "utf-8")).toBe(newer);
70+
});
6971
});

packages/core/src/studio-api/helpers/hfIdPersist.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,37 @@
11
import { ensureHfIds } from "../../parsers/hfIds.js";
2-
import { writeFileSync } from "node:fs";
3-
4-
export { ensureHfIds };
5-
6-
export function normalizeHfIds(html: string): { html: string; changed: boolean } {
7-
const normalized = ensureHfIds(html);
8-
return { html: normalized, changed: normalized !== html };
9-
}
2+
import { readFileSync, writeFileSync } from "node:fs";
103

4+
/**
5+
* Ensure `html` has `data-hf-id` attributes minted, and write the result back
6+
* to `filePath` if new ids were added.
7+
*
8+
* **Invariant:** `html` must be the raw file content read from `filePath` just
9+
* before this call. If `html` is constructed or transformed HTML the TOCTOU
10+
* guard (`current === html`) will never match and writes will silently be
11+
* skipped — no ids will reach disk.
12+
*/
1113
export function persistHfIdsIfNeeded(filePath: string, html: string): string {
12-
const { html: normalized, changed } = normalizeHfIds(html);
13-
if (changed) {
14+
const normalized = ensureHfIds(html);
15+
// Use attribute count instead of string equality: linkedom serialization may
16+
// normalize quote style and whitespace even when no ids were actually minted,
17+
// which would cause spurious writes on every request.
18+
const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length;
19+
const idsAfter = (normalized.match(/\bdata-hf-id=/g) ?? []).length;
20+
if (idsAfter > idsBefore) {
1421
try {
15-
writeFileSync(filePath, normalized, "utf-8");
16-
} catch {
17-
// non-fatal — serve with ids even if persist fails
22+
// Re-read before writing to guard against concurrent user saves. If the
23+
// file changed since we read it, skip the write — serving with ids is
24+
// still correct; the next request will re-persist. Best-effort only: a
25+
// user save landing between readFileSync and writeFileSync below can
26+
// still be overwritten (microsecond window).
27+
const current = readFileSync(filePath, "utf-8");
28+
if (current === html) {
29+
writeFileSync(filePath, normalized, "utf-8");
30+
}
31+
} catch (err) {
32+
// Non-fatal — serve with ids even if the disk write fails (e.g. read-only
33+
// filesystem, sandboxed environment). Log so the failure is diagnosable.
34+
console.warn("[hyperframes] persistHfIdsIfNeeded: failed to write ids to disk:", err);
1835
}
1936
}
2037
return normalized;

packages/core/src/studio-api/helpers/previewAdapter.test.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,21 @@ describe("T10 — PreviewAdapter contract (spec for R7)", () => {
6464
expect(adapter.elementAtPoint(0, 0)).toBeNull();
6565
});
6666

67-
it("skips elements whose computed opacity is 0 at the given playhead time", () => {
67+
it("skips elements whose currently-computed opacity is 0 (atTime is a caller-seek hint, not evaluated by the adapter)", () => {
6868
const elem = make("div", { "data-hf-id": "hf-zzzz" }, { opacity: "0" });
6969
const adapter = adapterWith(() => elem);
7070
expect(adapter.elementAtPoint(0, 0, { atTime: 1.0 })).toBeNull();
7171
});
72+
73+
it("returns null for nested data-hf-root without data-hf-id (treated same as outer stage root)", () => {
74+
const outerRoot = make("div", { "data-hf-root": "true" });
75+
const innerRoot = document.createElement("div");
76+
innerRoot.setAttribute("data-hf-root", "true");
77+
// no data-hf-id — no explicit id means no draggable target
78+
outerRoot.appendChild(innerRoot);
79+
const adapter = adapterWith(() => innerRoot);
80+
expect(adapter.elementAtPoint(0, 0)).toBeNull();
81+
});
7282
});
7383

7484
// ── applyDraft / revertDraft ───────────────────────────────────────────
@@ -130,19 +140,44 @@ describe("T10 — PreviewAdapter contract (spec for R7)", () => {
130140
expect(target.style.getPropertyValue("--hf-studio-offset-y")).toBe("15px");
131141
});
132142

143+
it("resize → move switch clears width/height props — no cross-type prop leak", () => {
144+
const target = make("div", { "data-hf-id": "hf-aaaa" });
145+
const adapter = adapterWith(() => null);
146+
adapter.applyDraft({ type: "resize", hfId: "hf-aaaa", w: 200, h: 100 });
147+
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 5 });
148+
// move props set
149+
expect(target.style.getPropertyValue("--hf-studio-offset-x")).toBe("10px");
150+
expect(target.style.getPropertyValue("--hf-studio-offset-y")).toBe("5px");
151+
// resize props cleared by the auto-revert before re-apply
152+
expect(target.style.getPropertyValue("--hf-studio-width")).toBe("");
153+
expect(target.style.getPropertyValue("--hf-studio-height")).toBe("");
154+
});
155+
156+
it("revertDraft after commitPreview is a no-op — does not restore stale translate", () => {
157+
const target = make("div", { "data-hf-id": "hf-aaaa" });
158+
target.style.setProperty("translate", "50px 0px");
159+
const adapter = adapterWith(() => null);
160+
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 0 });
161+
adapter.commitPreview();
162+
// simulate caller applying translate after commit
163+
target.style.setProperty("translate", "10px 0px");
164+
adapter.revertDraft(); // no gesture in flight — should be no-op
165+
expect(target.style.getPropertyValue("translate")).toBe("10px 0px");
166+
});
167+
133168
it("revertDraft is safe to call when no gesture is in progress (idempotent / no-op on empty marker)", () => {
134169
const adapter = adapterWith(() => null);
135170
expect(() => adapter.revertDraft()).not.toThrow();
136171
expect(() => adapter.revertDraft()).not.toThrow();
137172
});
138173

139-
it("elementAtPoint filtering is stable when playhead changes mid-drag — opacity re-evaluated per call", () => {
174+
it("elementAtPoint filtering is stable when inline opacity changes mid-drag — computed style re-evaluated per call", () => {
140175
const elem = make("div", { "data-hf-id": "hf-zzzz" });
141176
const adapter = adapterWith(() => elem);
142-
expect(adapter.elementAtPoint(0, 0, { atTime: 0 })).toBe(elem);
177+
expect(adapter.elementAtPoint(0, 0)).toBe(elem);
143178
// simulates GSAP seeking to a time where the element is hidden
144179
elem.style.setProperty("opacity", "0");
145-
expect(adapter.elementAtPoint(0, 0, { atTime: 1.0 })).toBeNull();
180+
expect(adapter.elementAtPoint(0, 0)).toBeNull();
146181
});
147182

148183
it("stage-root exclusion applies only to the outermost data-hf-root; nested sub-composition roots count as targets", () => {

0 commit comments

Comments
 (0)