/** * Cutover eligibility checks: whether a batch of patch ops is safe to route * through the SDK cutover path instead of the legacy server path. Split out of * sdkCutover.ts (which hit the packages/studio 600-line filesize cap) — this * block has no dependency on the persist/dispatch functions there. */ import type { PatchOperation } from "./sourcePatcher"; import { isAllowedHtmlAttribute, isSafeAttributeValue } from "./htmlAttrSafety"; const CUTOVER_OP_TYPES = new Set([ "inline-style", "text-content", "attribute", "html-attribute", ]); // Mirrors the SDK's RESERVED_ATTRS (mutate.ts): a bare `attribute` op is // force-prefixed `data-`, so e.g. property "end" → "data-end", which the SDK // rejects with a throw. Detect that up front and decline the whole batch so it // takes the server path cleanly, instead of throwing inside the dispatch and // silently falling back per op. // ponytail: small mirror of the SDK set; if the SDK adds a reserved attr, a new // op for it just reverts to the (working) throw→fallback path until synced. const RESERVED_CUTOVER_ATTRS = new Set([ "data-hf-id", "data-composition-id", "data-width", "data-height", "data-start", "data-end", "data-track-index", "data-hold-start", "data-hold-end", "data-hold-fill", ]); function sdkAttrName(op: PatchOperation): string | null { if (op.type === "attribute") { return op.property.startsWith("data-") ? op.property : `data-${op.property}`; } if (op.type === "html-attribute") return op.property; return null; } function mapsToReservedAttr(op: PatchOperation): boolean { const name = sdkAttrName(op); // Lowercase to match the SDK's validateSetAttribute (it lowercases before the // reserved check), so "DATA-START" is declined up front too; covers both // `attribute` (prefixed) and `html-attribute` (raw) ops. return name !== null && RESERVED_CUTOVER_ATTRS.has(name.toLowerCase()); } // ─── html-attribute safety ─────────────────────────────────────────────────── function hasUnsafeHtmlAttributeOp(ops: PatchOperation[]): boolean { return ops.some( (op) => op.type === "html-attribute" && (!isAllowedHtmlAttribute(op.property) || (op.value !== null && !isSafeAttributeValue(op.property, op.value))), ); } function hasChildScopedOp(ops: PatchOperation[]): boolean { return ops.some((op) => op.childSelector !== undefined); } function hasTextContentOp(ops: PatchOperation[]): boolean { return ops.some((op) => op.type === "text-content"); } function targetChildren(target: unknown): unknown[] | null { if (!target || typeof target !== "object" || !("children" in target)) return null; const children = target.children; return Array.isArray(children) ? children : null; } function elementTag(element: unknown): string | null { if (!element || typeof element !== "object" || !("tag" in element)) return null; const tag = element.tag; return typeof tag === "string" ? tag.toLowerCase() : null; } // Tags that are non-HTML namespace elements in a linkedom-parsed HTML body. // Mirrors the engine's `isHTMLElementTarget` (model.ts) which uses `instanceof // HTMLElement` — that runtime check catches the same set, but we can't use it // here because `target` is a plain SDK object, not a DOM Element. If linkedom // (or a future parser) surfaces additional foreign-content elements as // non-HTMLElement, add them here. const NON_HTML_CHILD_TAGS = new Set(["svg", "math"]); export function shouldDeclineTextCutoverForTarget(target: unknown, ops: PatchOperation[]): boolean { if (!hasTextContentOp(ops)) return false; const children = targetChildren(target); if (!children) return false; // Legacy patch-element replaces the whole element for multi-child targets and // for single non-HTML children. The SDK text patch stream stores a scalar // inverse, so those shapes cannot be made both byte-identical and undo-safe // here. Let the server path remain authoritative for them. if (children.length > 1) return true; const tag = elementTag(children[0]); return tag !== null && NON_HTML_CHILD_TAGS.has(tag); } export function shouldUseSdkCutover( flagEnabled: boolean, hasSession: boolean, hfId: string | null | undefined, ops: PatchOperation[], ): boolean { return ( flagEnabled && hasSession && !!hfId && ops.length > 0 && ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) && // SDK edit ops target only the element hfId; child-scoped patch ops need the server path. !hasChildScopedOp(ops) && !ops.some(mapsToReservedAttr) && !hasUnsafeHtmlAttributeOp(ops) ); }