fix(sdk,studio): restore DOM edit cutover parity (#1565)

- Add splitStyleDeclarations with quote/paren-aware CSS parsing
- Fix backslash escape handling inside quoted CSS string values
- Close html-attribute safety gap in SDK cutover (event handlers, dangerous URIs)
- Consolidate HTML attribute safety constants to core/utils/htmlAttrSafety.ts
- Extract NON_HTML_CHILD_TAGS set for foreign-content decline gate
- Add sdkCutoverParity test corpus (shorthand/longhand, mixed batches)
This commit is contained in:
Miguel Ángel
2026-06-19 15:37:10 -04:00
committed by GitHub
parent 4e32c5e0fe
commit 758eda995c
12 changed files with 534 additions and 119 deletions
+54 -2
View File
@@ -8,6 +8,7 @@ import { trackStudioEvent } from "./studioTelemetry";
import { markSelfWrite } from "../hooks/sdkSelfWriteRegistry";
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";
import { isAllowedHtmlAttribute, isSafeAttributeValue } from "./htmlAttrSafety";
const CUTOVER_OP_TYPES = new Set<PatchOperation["type"]>([
"inline-style",
@@ -52,6 +53,54 @@ function mapsToReservedAttr(op: PatchOperation): boolean {
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 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 as { children?: unknown }).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 as { tag?: unknown }).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"]);
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,
@@ -64,7 +113,8 @@ export function shouldUseSdkCutover(
!!hfId &&
ops.length > 0 &&
ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) &&
!ops.some(mapsToReservedAttr)
!ops.some(mapsToReservedAttr) &&
!hasUnsafeHtmlAttributeOp(ops)
);
}
@@ -186,7 +236,9 @@ export async function sdkCutoverPersist(
if (!sdkSession) return false;
const hfId = selection.hfId;
if (!hfId) return false;
if (!sdkSession.getElement(hfId)) return false;
const target = sdkSession.getElement(hfId);
if (!target) return false;
if (shouldDeclineTextCutoverForTarget(target, ops)) return false;
if (wrongCompositionFile(deps, targetPath)) return false;
try {
const before = sdkSession.serialize();