Files
hyperframes/packages/studio/src/utils/studioSaveDiagnostics.ts
T
Miguel Ángel e6e0d97cc5 fix(studio): surface persist failures with toast and guarded revert (#1910)
* test(studio): add design-panel QA fixture and triage matrix

Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.

* fix(studio): make canvas selection hit intended elements

- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling

* fix(studio): close remaining selection-layer review findings

- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
  blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
  so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
  check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
  playback paused if it was already playing

* fix(studio-server): child-scoped patch operations with batch abort

- PatchOperation gains optional childSelector/childIndex resolved under the matched parent
- pre-pass resolves every op target; any miss aborts the batch with matched:false, no partial write
- style-decl parsing extracted to sourceStyleMutation to stay under the file-size cap
- new ./source-mutation subpath export (mirrors ./finite-mutation)

* fix(studio): per-child patch op builders and persist-seam harness

- buildTextFieldChildLocator indexes over the parent's full same-tag child list
- buildTextFieldChildOperations emits per-field ops for same-shape multi-field edits
- SDK cutover declines child-scoped batches (hfId mapping would hit the parent)
- persist-seam integration harness drives real client ops through patchElementInHtml

* fix(studio): fail closed on unresolved text-field child index

buildTextFieldChildLocator guessed a synthetic field's position by
counting same-tag "child" fields elsewhere in the array whenever
sourceChildIndex was absent. That heuristic is unreachable today (the
count-mismatch guard in buildTextFieldChildOperations already refuses
add/remove edits before it's reached) but would silently locate the
wrong element for a future caller that wires up synthetic-field
support without also computing a real sourceChildIndex. Return null
instead so the caller falls back to the unsupported-structure path.

* fix(studio): surface persist failures with toast and guarded revert

- matched:false and persist errors toast, warn structurally, and revert the optimistic write
- reverts guarded by a per-property version counter so stale failures never stomp newer edits
- structural text-field edits refuse persist instead of writing escaped markup
- multi-field child edits persist via per-child ops; shared commit runner extracted

* fix(studio): revert data-attribute and html-attribute commits on persist failure

commitDataAttribute and handleDomHtmlAttributeCommit toasted on failure but
never reverted the optimistic attribute write, leaving the preview showing an
edit that never reached disk (the exact bug this PR closes for style commits).
Extracted into useDomEditAttributeCommits.ts (useDomEditTextCommits.ts was at
the file-size cap) and routed through runDomEditCommit with a per-target+
attribute version guard, mirroring handleDomStyleCommit.

* fix(studio): close coupled persist-hook review findings

Three findings from R2 review that must land together: a patch-rejection
toast doubled up with the generic persist-failure toast (StudioSaveHttpError
had no alreadyToasted marker), a failed prepareContent write (e.g. font-face
injection) reverted and re-toasted a change the server had already
persisted, and text-commit shouldRevert only rolled back on one narrow
error type instead of any persist failure.
2026-07-03 18:22:22 -07:00

204 lines
7.0 KiB
TypeScript

import { trackStudioEvent } from "./studioTelemetry";
type StudioTelemetryValue = string | number | boolean | null | undefined;
const STUDIO_SAVE_ATTEMPT_PROPERTY = "__studioSaveAttempt";
export interface StudioSaveFailureInput {
source: string;
error: unknown;
statusCode?: number | null;
filePath?: string | null;
mutationType?: string | null;
attempt?: number | null;
label?: string | null;
targetId?: string | null;
targetSelector?: string | null;
targetSourceFile?: string | null;
}
export class StudioSaveHttpError extends Error {
readonly statusCode: number;
readonly alreadyToasted: boolean;
constructor(message: string, statusCode: number, options: { alreadyToasted?: boolean } = {}) {
super(message);
this.name = "StudioSaveHttpError";
this.statusCode = statusCode;
this.alreadyToasted = options.alreadyToasted ?? false;
}
}
export class StudioSaveNetworkError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "StudioSaveNetworkError";
}
}
function readNumericProperty(value: object, key: string): number | undefined {
const record = value as Record<string, unknown>;
const property = record[key];
return typeof property === "number" && Number.isFinite(property) ? property : undefined;
}
function createStudioSaveAbortError(): Error {
if (typeof DOMException !== "undefined") return new DOMException("Save aborted", "AbortError");
const error = new Error("Save aborted");
error.name = "AbortError";
return error;
}
function throwIfStudioSaveAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw createStudioSaveAbortError();
}
function attachStudioSaveAttempt(error: unknown, attempt: number): unknown {
if (!error || typeof error !== "object") return error;
try {
Object.defineProperty(error, STUDIO_SAVE_ATTEMPT_PROPERTY, {
value: attempt,
configurable: true,
});
} catch {
// Best-effort diagnostic only.
}
return error;
}
export function getStudioSaveErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error.trim()) return error;
return "Unknown save failure";
}
export function getStudioSaveStatusCode(error: unknown): number | undefined {
if (!error || typeof error !== "object") return undefined;
const direct =
readNumericProperty(error, "statusCode") ??
readNumericProperty(error, "status") ??
readNumericProperty(error, "status_code");
if (direct != null) return direct;
const cause = (error as { cause?: unknown }).cause;
if (cause && cause !== error) return getStudioSaveStatusCode(cause);
return undefined;
}
function getStudioSaveAttempt(error: unknown): number | undefined {
if (!error || typeof error !== "object") return undefined;
const direct = readNumericProperty(error, STUDIO_SAVE_ATTEMPT_PROPERTY);
if (direct != null) return direct;
const cause = (error as { cause?: unknown }).cause;
if (cause && cause !== error) return getStudioSaveAttempt(cause);
return undefined;
}
function isStudioSaveAbortError(error: unknown): boolean {
return error instanceof Error && error.name === "AbortError";
}
function isRetryableStudioSaveError(error: unknown): boolean {
if (isStudioSaveAbortError(error)) return false;
if (error instanceof StudioSaveNetworkError) return true;
const statusCode = getStudioSaveStatusCode(error);
if (statusCode == null) return false;
return statusCode === 408 || statusCode === 425 || statusCode === 429 || statusCode >= 500;
}
export function buildStudioSaveFailureProperties(
input: StudioSaveFailureInput,
): Record<string, StudioTelemetryValue> {
const statusCode = input.statusCode ?? getStudioSaveStatusCode(input.error) ?? null;
const attempt = input.attempt ?? getStudioSaveAttempt(input.error) ?? undefined;
return {
source: input.source,
error_message: getStudioSaveErrorMessage(input.error),
status_code: statusCode,
file_path: input.filePath ?? input.targetSourceFile ?? undefined,
mutation_type: input.mutationType ?? undefined,
attempt,
label: input.label ?? undefined,
target_id: input.targetId ?? undefined,
target_selector: input.targetSelector ?? undefined,
target_source_file: input.targetSourceFile ?? undefined,
};
}
export function trackStudioSaveFailure(input: StudioSaveFailureInput): void {
trackStudioEvent("save_failure", buildStudioSaveFailureProperties(input));
}
export async function createStudioSaveHttpError(
response: Response,
fallbackMessage: string,
options: { alreadyToasted?: boolean } = {},
): Promise<StudioSaveHttpError> {
let body = "";
try {
body = await response.text();
} catch {
body = "";
}
const detail = body.trim().slice(0, 300);
const message = detail
? `${fallbackMessage} (${response.status}): ${detail}`
: `${fallbackMessage} (${response.status})`;
return new StudioSaveHttpError(message, response.status, options);
}
export async function retryStudioSave<T>(
operation: (attempt: number) => Promise<T>,
options: {
retries?: number;
baseDelayMs?: number;
maxDelayMs?: number;
jitterRatio?: number;
random?: () => number;
signal?: AbortSignal;
shouldRetry?: (error: unknown, attempt: number) => boolean;
sleep?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
} = {},
): Promise<T> {
const retries = options.retries ?? 3;
const baseDelayMs = options.baseDelayMs ?? 500;
const maxDelayMs = options.maxDelayMs ?? 8000;
const jitterRatio = options.jitterRatio ?? 0.25;
const random = options.random ?? Math.random;
const shouldRetry = options.shouldRetry ?? isRetryableStudioSaveError;
const sleep =
options.sleep ??
((delayMs: number, signal?: AbortSignal) =>
new Promise<void>((resolve, reject) => {
throwIfStudioSaveAborted(signal);
const onAbort = () => {
globalThis.clearTimeout(timeout);
reject(createStudioSaveAbortError());
};
const timeout = globalThis.setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, delayMs);
signal?.addEventListener("abort", onAbort, { once: true });
}));
const maxAttempts = retries + 1;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
throwIfStudioSaveAborted(options.signal);
return await operation(attempt);
} catch (error) {
const failure = attachStudioSaveAttempt(error, attempt);
if (attempt >= maxAttempts || !shouldRetry(failure, attempt)) throw failure;
const retryIndex = attempt - 1;
const exponentialDelay = Math.min(baseDelayMs * 2 ** retryIndex, maxDelayMs);
const jitterSpan = exponentialDelay * jitterRatio;
const jitteredDelay = Math.round(exponentialDelay + (random() * 2 - 1) * jitterSpan);
const delayMs = Math.max(0, Math.min(maxDelayMs, jitteredDelay));
await sleep(delayMs, options.signal);
}
}
throw new Error("Save retry loop exited unexpectedly");
}