mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): shrink useDomEditCommits.ts under the file-size gate
CI's file-size check (which diffs against origin/main, not per-commit like the local lefthook gate) flagged useDomEditCommits.ts at 602 lines. Extracted the standalone atomic-patch-batch helpers (formatUnsafeFieldList, getErrorDetail, readErrorResponseBody, formatPatchRejectionMessage, patchElementBatches, batchesAreInlineStyleOnly, AtomicElementPatchConvergenceError) into useDomEditCommitsHelpers.ts — none of them close over hook state, so this is a pure move. useDomEditCommits.ts is now 451 lines. Typecheck/oxlint/oxfmt clean; useDomEditCommits.test.tsx (28 tests) and the full studio suite unaffected.
This commit is contained in:
@@ -12,11 +12,7 @@ import {
|
||||
import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
|
||||
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
import type {
|
||||
CommitDomEditPatchBatches,
|
||||
DomEditPatchBatch,
|
||||
PersistDomEditOperations,
|
||||
} from "./domEditCommitTypes";
|
||||
import type { CommitDomEditPatchBatches, PersistDomEditOperations } from "./domEditCommitTypes";
|
||||
import type { PatchOperation } from "../utils/sourcePatcher";
|
||||
import {
|
||||
DomEditPersistUnsafeValueError,
|
||||
@@ -27,30 +23,15 @@ import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
|
||||
import { useDomEditTextCommits } from "./useDomEditTextCommits";
|
||||
import { useDomGeometryCommits } from "./useDomGeometryCommits";
|
||||
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
||||
import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
|
||||
return fields.map((field) => field.path).join(", ");
|
||||
}
|
||||
|
||||
function getErrorDetail(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function readErrorResponseBody(
|
||||
response: Response,
|
||||
): Promise<{ error?: string; fields?: string[] } | null> {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) return null;
|
||||
return (await response.json().catch(() => null)) as { error?: string; fields?: string[] } | null;
|
||||
}
|
||||
|
||||
function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string {
|
||||
if (!body?.error) return "Couldn't save edit";
|
||||
return `Couldn't save edit: ${body.error}${formatFieldsSuffix(body.fields)}`;
|
||||
}
|
||||
import {
|
||||
AtomicElementPatchConvergenceError,
|
||||
batchesAreInlineStyleOnly,
|
||||
formatPatchRejectionMessage,
|
||||
formatUnsafeFieldList,
|
||||
getErrorDetail,
|
||||
patchElementBatches,
|
||||
readErrorResponseBody,
|
||||
} from "./useDomEditCommitsHelpers";
|
||||
|
||||
interface RecordEditInput {
|
||||
label: string;
|
||||
@@ -60,138 +41,6 @@ interface RecordEditInput {
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}
|
||||
|
||||
/** Human-readable identifier for a batch patch target (for the unmatched warning). */
|
||||
function describeBatchPatchTarget(patch: DomEditPatchBatch["patches"][number]): string {
|
||||
return patch.target.id ?? patch.target.hfId ?? patch.target.selector ?? "(unaddressed)";
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface server-reported unmatched patches. The server atomically refuses the
|
||||
* whole multi-file gesture; the caller uses `durable: false` to roll back and
|
||||
* reload, so report the refusal without turning it into a second failure.
|
||||
*/
|
||||
function reportUnmatchedBatchPatches(batch: DomEditPatchBatch, matched: boolean[]): void {
|
||||
const unmatchedIds = batch.patches
|
||||
.filter((_, index) => matched[index] === false)
|
||||
.map(describeBatchPatchTarget);
|
||||
if (unmatchedIds.length === 0) return;
|
||||
console.warn(
|
||||
`[studio] z-index reorder: server could not match ${unmatchedIds.length} patch target(s) in ` +
|
||||
`${batch.sourceFile} (the whole z-order gesture will revert on reload):`,
|
||||
unmatchedIds.join(", "),
|
||||
);
|
||||
trackStudioSaveFailure({
|
||||
source: "dom_edit",
|
||||
error: new Error(`Batch patch target(s) unmatched: ${unmatchedIds.join(", ")}`),
|
||||
filePath: batch.sourceFile,
|
||||
mutationType: "z-reorder-unmatched",
|
||||
});
|
||||
}
|
||||
|
||||
interface AtomicElementPatchFile {
|
||||
sourceFile: string;
|
||||
changed: boolean;
|
||||
matched?: boolean[];
|
||||
before: string;
|
||||
after: string;
|
||||
}
|
||||
|
||||
class AtomicElementPatchConvergenceError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
this.name = "AtomicElementPatchConvergenceError";
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the atomic response contract in one guard so callers do not compose validity.
|
||||
// fallow-ignore-next-line complexity
|
||||
function isAtomicElementPatchFile(value: unknown): value is AtomicElementPatchFile {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"sourceFile" in value &&
|
||||
typeof value.sourceFile === "string" &&
|
||||
"changed" in value &&
|
||||
typeof value.changed === "boolean" &&
|
||||
(!("matched" in value) ||
|
||||
(Array.isArray(value.matched) &&
|
||||
value.matched.every((matched) => typeof matched === "boolean"))) &&
|
||||
"before" in value &&
|
||||
typeof value.before === "string" &&
|
||||
"after" in value &&
|
||||
typeof value.after === "string" &&
|
||||
value.changed === (value.before !== value.after)
|
||||
);
|
||||
}
|
||||
|
||||
// This is the single client owner for dispatching and validating the aggregate
|
||||
// atomic endpoint. Splitting validation from the request would weaken that wire contract.
|
||||
// fallow-ignore-next-line complexity
|
||||
async function patchElementBatches(projectId: string, batches: DomEditPatchBatch[]) {
|
||||
const body = JSON.stringify({ batches });
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element-batches`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const rejection = await readErrorResponseBody(response);
|
||||
throw new StudioSaveHttpError(formatPatchRejectionMessage(rejection), response.status);
|
||||
}
|
||||
const result: unknown = await response.json().catch(() => null);
|
||||
if (
|
||||
typeof result !== "object" ||
|
||||
result === null ||
|
||||
!("durable" in result) ||
|
||||
typeof result.durable !== "boolean" ||
|
||||
!("files" in result) ||
|
||||
!Array.isArray(result.files) ||
|
||||
result.files.length !== batches.length ||
|
||||
!result.files.every(isAtomicElementPatchFile) ||
|
||||
(!result.durable && result.files.some((file) => file.changed))
|
||||
) {
|
||||
throw new StudioSaveHttpError("Invalid atomic element patch response", 502);
|
||||
}
|
||||
const files = result.files.map((file, index) => {
|
||||
const batch = batches[index];
|
||||
const matched = file.matched ?? [];
|
||||
if (
|
||||
!batch ||
|
||||
file.sourceFile !== batch.sourceFile ||
|
||||
(matched.length !== 0 && matched.length !== batch.patches.length)
|
||||
) {
|
||||
throw new StudioSaveHttpError("Invalid atomic element patch response", 502);
|
||||
}
|
||||
reportUnmatchedBatchPatches(batch, matched);
|
||||
return {
|
||||
...file,
|
||||
matched,
|
||||
allMatched: matched.length === batch.patches.length && matched.every(Boolean),
|
||||
};
|
||||
});
|
||||
return { durable: result.durable, files };
|
||||
} catch (error) {
|
||||
throw new AtomicElementPatchConvergenceError(getErrorDetail(error), { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A batch is reload-skippable only when it is style-only: every operation is an
|
||||
* `inline-style` write. The z-reorder commit applies those exact styles to the
|
||||
* live iframe DOM synchronously, so persisting them adds nothing the preview
|
||||
* doesn't already show. Any other op type (attribute / text-content / …) can
|
||||
* have server-side semantics the live DOM hasn't mirrored — reload for those.
|
||||
*/
|
||||
function batchesAreInlineStyleOnly(batches: DomEditPatchBatch[]): boolean {
|
||||
return batches.every((batch) =>
|
||||
batch.patches.every((patch) => patch.operations.every((op) => op.type === "inline-style")),
|
||||
);
|
||||
}
|
||||
|
||||
export interface UseDomEditCommitsParams {
|
||||
activeCompPath: string | null;
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { StudioSaveHttpError, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
||||
import type { DomEditPatchBatch } from "./domEditCommitTypes";
|
||||
import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
|
||||
|
||||
export function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
|
||||
return fields.map((field) => field.path).join(", ");
|
||||
}
|
||||
|
||||
export function getErrorDetail(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export async function readErrorResponseBody(
|
||||
response: Response,
|
||||
): Promise<{ error?: string; fields?: string[] } | null> {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) return null;
|
||||
return (await response.json().catch(() => null)) as { error?: string; fields?: string[] } | null;
|
||||
}
|
||||
|
||||
export function formatPatchRejectionMessage(
|
||||
body: { error?: string; fields?: string[] } | null,
|
||||
): string {
|
||||
if (!body?.error) return "Couldn't save edit";
|
||||
return `Couldn't save edit: ${body.error}${formatFieldsSuffix(body.fields)}`;
|
||||
}
|
||||
|
||||
/** Human-readable identifier for a batch patch target (for the unmatched warning). */
|
||||
function describeBatchPatchTarget(patch: DomEditPatchBatch["patches"][number]): string {
|
||||
return patch.target.id ?? patch.target.hfId ?? patch.target.selector ?? "(unaddressed)";
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface server-reported unmatched patches. The server atomically refuses the
|
||||
* whole multi-file gesture; the caller uses `durable: false` to roll back and
|
||||
* reload, so report the refusal without turning it into a second failure.
|
||||
*/
|
||||
function reportUnmatchedBatchPatches(batch: DomEditPatchBatch, matched: boolean[]): void {
|
||||
const unmatchedIds = batch.patches
|
||||
.filter((_, index) => matched[index] === false)
|
||||
.map(describeBatchPatchTarget);
|
||||
if (unmatchedIds.length === 0) return;
|
||||
console.warn(
|
||||
`[studio] z-index reorder: server could not match ${unmatchedIds.length} patch target(s) in ` +
|
||||
`${batch.sourceFile} (the whole z-order gesture will revert on reload):`,
|
||||
unmatchedIds.join(", "),
|
||||
);
|
||||
trackStudioSaveFailure({
|
||||
source: "dom_edit",
|
||||
error: new Error(`Batch patch target(s) unmatched: ${unmatchedIds.join(", ")}`),
|
||||
filePath: batch.sourceFile,
|
||||
mutationType: "z-reorder-unmatched",
|
||||
});
|
||||
}
|
||||
|
||||
interface AtomicElementPatchFile {
|
||||
sourceFile: string;
|
||||
changed: boolean;
|
||||
matched?: boolean[];
|
||||
before: string;
|
||||
after: string;
|
||||
}
|
||||
|
||||
export class AtomicElementPatchConvergenceError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
this.name = "AtomicElementPatchConvergenceError";
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the atomic response contract in one guard so callers do not compose validity.
|
||||
// fallow-ignore-next-line complexity
|
||||
function isAtomicElementPatchFile(value: unknown): value is AtomicElementPatchFile {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"sourceFile" in value &&
|
||||
typeof value.sourceFile === "string" &&
|
||||
"changed" in value &&
|
||||
typeof value.changed === "boolean" &&
|
||||
(!("matched" in value) ||
|
||||
(Array.isArray(value.matched) &&
|
||||
value.matched.every((matched) => typeof matched === "boolean"))) &&
|
||||
"before" in value &&
|
||||
typeof value.before === "string" &&
|
||||
"after" in value &&
|
||||
typeof value.after === "string" &&
|
||||
value.changed === (value.before !== value.after)
|
||||
);
|
||||
}
|
||||
|
||||
// This is the single client owner for dispatching and validating the aggregate
|
||||
// atomic endpoint. Splitting validation from the request would weaken that wire contract.
|
||||
// fallow-ignore-next-line complexity
|
||||
export async function patchElementBatches(projectId: string, batches: DomEditPatchBatch[]) {
|
||||
const body = JSON.stringify({ batches });
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element-batches`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const rejection = await readErrorResponseBody(response);
|
||||
throw new StudioSaveHttpError(formatPatchRejectionMessage(rejection), response.status);
|
||||
}
|
||||
const result: unknown = await response.json().catch(() => null);
|
||||
if (
|
||||
typeof result !== "object" ||
|
||||
result === null ||
|
||||
!("durable" in result) ||
|
||||
typeof result.durable !== "boolean" ||
|
||||
!("files" in result) ||
|
||||
!Array.isArray(result.files) ||
|
||||
result.files.length !== batches.length ||
|
||||
!result.files.every(isAtomicElementPatchFile) ||
|
||||
(!result.durable && result.files.some((file) => file.changed))
|
||||
) {
|
||||
throw new StudioSaveHttpError("Invalid atomic element patch response", 502);
|
||||
}
|
||||
const files = result.files.map((file, index) => {
|
||||
const batch = batches[index];
|
||||
const matched = file.matched ?? [];
|
||||
if (
|
||||
!batch ||
|
||||
file.sourceFile !== batch.sourceFile ||
|
||||
(matched.length !== 0 && matched.length !== batch.patches.length)
|
||||
) {
|
||||
throw new StudioSaveHttpError("Invalid atomic element patch response", 502);
|
||||
}
|
||||
reportUnmatchedBatchPatches(batch, matched);
|
||||
return {
|
||||
...file,
|
||||
matched,
|
||||
allMatched: matched.length === batch.patches.length && matched.every(Boolean),
|
||||
};
|
||||
});
|
||||
return { durable: result.durable, files };
|
||||
} catch (error) {
|
||||
throw new AtomicElementPatchConvergenceError(getErrorDetail(error), { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A batch is reload-skippable only when it is style-only: every operation is an
|
||||
* `inline-style` write. The z-reorder commit applies those exact styles to the
|
||||
* live iframe DOM synchronously, so persisting them adds nothing the preview
|
||||
* doesn't already show. Any other op type (attribute / text-content / …) can
|
||||
* have server-side semantics the live DOM hasn't mirrored — reload for those.
|
||||
*/
|
||||
export function batchesAreInlineStyleOnly(batches: DomEditPatchBatch[]): boolean {
|
||||
return batches.every((batch) =>
|
||||
batch.patches.every((patch) => patch.operations.every((op) => op.type === "inline-style")),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user