Skip to content

Commit edf7306

Browse files
vanceingallsclaude
andcommitted
fix(sdk): address review — live-DOM query cache, single parse, style parse dedup
- getElements/getElement/find now walk the live linkedom DOM via buildRoots with a lazily-built cache invalidated on dispatch/applyPatches — no serialize→ensureHfIds→parseHTML round trip per query - openComposition parses once (parseMutable); dropped discarded _doc constructor param and the redundant buildDocument call - document.ts buildElement reuses model.ts getElementStyles — removes duplicated parseInlineStyles (also fixes custom-prop camelCase mangling) - JSDoc note: empty batch() still fires change handlers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9fe2783 commit edf7306

2 files changed

Lines changed: 40 additions & 36 deletions

File tree

packages/sdk/src/document.ts

Lines changed: 20 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import { parseHTML } from "linkedom";
1212
import { ensureHfIds } from "@hyperframes/core/hf-ids";
13+
import { getElementStyles } from "./engine/model.js";
1314
import type { HyperFramesElement, SdkDocument } from "./types.js";
1415

1516
// Tags that carry no editable content and must not enter the element tree.
@@ -24,22 +25,6 @@ const EXCLUDED_TAGS = new Set([
2425
"head",
2526
]);
2627

27-
// fallow-ignore-next-line complexity
28-
function parseInlineStyles(styleAttr: string): Record<string, string> {
29-
const result: Record<string, string> = {};
30-
for (const decl of styleAttr.split(";")) {
31-
const idx = decl.indexOf(":");
32-
if (idx === -1) continue;
33-
const prop = decl.slice(0, idx).trim();
34-
const value = decl.slice(idx + 1).trim();
35-
if (!prop || !value) continue;
36-
// Convert kebab-case → camelCase to match CSSStyleDeclaration convention
37-
const camel = prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
38-
result[camel] = value;
39-
}
40-
return result;
41-
}
42-
4328
function ownText(el: Element): string | null {
4429
let text = "";
4530
el.childNodes.forEach((n) => {
@@ -57,8 +42,7 @@ function buildElement(el: Element): HyperFramesElement | null {
5742
const id = el.getAttribute("data-hf-id") ?? "";
5843
if (!id) return null; // should never happen after ensureHfIds, but guard defensively
5944

60-
const styleAttr = (el as HTMLElement).getAttribute?.("style") ?? "";
61-
const inlineStyles = parseInlineStyles(styleAttr);
45+
const inlineStyles = getElementStyles(el);
6246

6347
const classAttr = el.getAttribute("class") ?? "";
6448
const classNames = classAttr
@@ -140,12 +124,28 @@ function extractDuration(doc: Document): number | null {
140124
return dur ? parseFloat(dur) : null;
141125
}
142126

127+
/**
128+
* Build the element tree from an already-parsed (hf-id-stamped) linkedom Document.
129+
* Walks the live DOM directly — no serialize/re-parse round trip. This is what
130+
* the session's query API uses against its mutable document.
131+
*/
132+
export function buildRoots(document: Document): HyperFramesElement[] {
133+
const body = document.body;
134+
const roots: HyperFramesElement[] = [];
135+
if (body) {
136+
for (const child of Array.from(body.children)) {
137+
const built = buildElement(child);
138+
if (built) roots.push(built);
139+
}
140+
}
141+
return roots;
142+
}
143+
143144
/**
144145
* Parse an HTML string into the SDK document model.
145146
* Calls ensureHfIds first so every element has a stable data-hf-id.
146147
* Uses linkedom — node-safe (works in agents, CI, server-side).
147148
*/
148-
// fallow-ignore-next-line complexity
149149
export function buildDocument(html: string): SdkDocument {
150150
const stamped = ensureHfIds(html);
151151

@@ -155,20 +155,10 @@ export function buildDocument(html: string): SdkDocument {
155155
? parseHTML(`<!DOCTYPE html><html><head></head><body>${stamped}</body></html>`)
156156
: parseHTML(stamped);
157157

158-
const body = document.body;
159-
const roots: HyperFramesElement[] = [];
160-
161-
if (body) {
162-
for (const child of Array.from(body.children)) {
163-
const built = buildElement(child);
164-
if (built) roots.push(built);
165-
}
166-
}
167-
168158
const dims = extractDimensions(document);
169159

170160
return {
171-
roots,
161+
roots: buildRoots(document),
172162
gsapScript: extractGsapScript(document),
173163
styles: extractStyles(document),
174164
width: dims.width,

packages/sdk/src/session.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,8 @@ import type {
2323
ElementHandle,
2424
} from "./types.js";
2525
import { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js";
26-
import { buildDocument, flatElements } from "./document.js";
26+
import { buildRoots, flatElements } from "./document.js";
2727
import type { PersistAdapter, PreviewAdapter } from "./adapters/types.js";
28-
import type { SdkDocument } from "./types.js";
2928
import { parseMutable } from "./engine/model.js";
3029
import type { ParsedDocument } from "./engine/model.js";
3130
import { applyOp, validateOp } from "./engine/mutate.js";
@@ -58,6 +57,9 @@ class CompositionImpl implements Composition {
5857
/** Accumulated override-set — T3 embedded mode fold contract. */
5958
private overrides: OverrideSet;
6059

60+
/** Lazily-built element snapshot, invalidated on every mutation. */
61+
private elementsCache: ElementSnapshot[] | null = null;
62+
6163
private currentSelection: string[] = [];
6264

6365
private changeHandlers: Array<() => void> = [];
@@ -76,7 +78,7 @@ class CompositionImpl implements Composition {
7678
private batchOpTypes: string[] = [];
7779
private batchOrigin: unknown = ORIGIN_LOCAL;
7880

79-
constructor(_doc: SdkDocument, parsed: ParsedDocument, opts: OpenCompositionOptions) {
81+
constructor(parsed: ParsedDocument, opts: OpenCompositionOptions) {
8082
this.parsed = parsed;
8183
this.persist = opts.persist;
8284
this.preview = opts.preview;
@@ -143,7 +145,9 @@ class CompositionImpl implements Composition {
143145
// ── Query API (F1) ───────────────────────────────────────────────────────────
144146

145147
getElements(): ElementSnapshot[] {
146-
return flatElements(buildDocument(serializeDocument(this.parsed)).roots);
148+
// Walk the live linkedom DOM directly — no serialize/re-parse round trip.
149+
this.elementsCache ??= flatElements(buildRoots(this.parsed.document));
150+
return [...this.elementsCache];
147151
}
148152

149153
getElement(id: HfId): ElementSnapshot | null {
@@ -209,6 +213,8 @@ class CompositionImpl implements Composition {
209213
return;
210214
}
211215

216+
this.elementsCache = null;
217+
212218
// Update override-set from forward patches
213219
for (const p of forward) {
214220
const key = pathToKey(p.path);
@@ -229,6 +235,12 @@ class CompositionImpl implements Composition {
229235
}
230236
}
231237

238+
/**
239+
* Coalesce multiple dispatches into one undo entry / one patch event.
240+
* Note: a batch that produces no effective mutations still fires 'change'
241+
* handlers (parity with no-op dispatch) — subscribers must not assume
242+
* silence when wrapping speculative operations.
243+
*/
232244
// fallow-ignore-next-line complexity
233245
batch(fn: () => void, opts?: { origin?: unknown }): void {
234246
const origin = opts?.origin ?? ORIGIN_LOCAL;
@@ -331,6 +343,7 @@ class CompositionImpl implements Composition {
331343
// For applyPatches the "inverse" is the current state before application.
332344
// Emit a patch event so history and subscribers stay in sync.
333345
applyPatchesToDocument(this.parsed, patches);
346+
this.elementsCache = null;
334347

335348
// Update override-set
336349
for (const p of patches) {
@@ -373,9 +386,10 @@ export async function openComposition(
373386
html: string,
374387
opts?: OpenCompositionOptions,
375388
): Promise<Composition> {
376-
const doc = buildDocument(html);
389+
// Single parse: parseMutable stamps hf-ids + builds the live linkedom DOM;
390+
// the query API derives element snapshots from it lazily.
377391
const parsed = parseMutable(html);
378-
const session = new CompositionImpl(doc, parsed, opts ?? {});
392+
const session = new CompositionImpl(parsed, opts ?? {});
379393

380394
const isEmbedded = opts?.overrides !== undefined;
381395

0 commit comments

Comments
 (0)