fix(studio): wire Grade rollback through the real commit path, scope async completions

Fixes two of the three adversarial findings from the second #2416 tip
re-review; the third is a pre-existing runtime-protocol gap, explained in
the PR thread rather than patched here.

- The Grade rollback added in the previous commit could never fire through
  the real Studio callback: runDomEditCommit (the shared commit runner used
  by every data-attribute commit, not just Grade) catches persist failures
  internally and always resolves, reporting outcome only via its own
  onError side effect. A caller awaiting the promise never sees a
  rejection, so the revert-on-reject logic was dead code against the
  actual app. Added an optional onSettled(ok) callback to
  DomEditCommitRunnerConfig (purely additive — every existing caller that
  doesn't pass it is unaffected) and threaded it through
  commitDataAttribute -> handleDomAttributeLiveCommit -> the
  onSetAttributeLive prop type (now accepts an optional 3rd argument)  ->
  useColorGradingController, which now drives the revert from the real
  signal. The promise-rejection path stays as a fallback for any other
  implementation of onSetAttributeLive that rejects instead.

- Selection flushing performed a real side effect (writing the outgoing
  element's pending edit) during the render-phase identity-reset block.
  Adjusting STATE during render (comparing against a ref) is React's
  documented pattern, but it doesn't license actual I/O — React can invoke
  render more than once per commit, which could double-fire or misorder
  the write. The reset block now only enqueues the flush (a pure ref
  write); a new effect keyed on the identity performs it after commit.

- Async persist completions (both the onSettled callback and its promise-
  rejection fallback) now capture the identity key the attempt was made
  for and check it against the CURRENT identity before touching
  confirmedGradingRef/grading/runtimeStatus. Without this, a persist that
  settles after selection has moved on to a THIRD element could clobber
  that element's freshly-reset state with a result that belongs to an
  element no longer selected.

Not fixed here: the runtime Grade target (HfColorGradingTarget, used by
core's resolveTarget to find the DOM element inside the preview iframe)
has no source-file/composition-scope discriminator, matching the same gap
selectionIdentityKey had before this stack — but fixing it means changing
a wire-protocol type shared across core/player/studio and the legacy
ColorGradingSection too. hfId (checked first, before id/selector) is
minted uniquely per element at parse time in the common case, so this is
a narrow residual risk for hfId-less same-selector elements across
different source files, not a regression introduced by this stack.
Flagged as a follow-up in the PR thread.

New/updated regression tests: real onSettled(false) path (distinct from
the promise-rejection fallback), and a stale in-flight persist settling
after selection has moved on twice more. Full studio suite still at the
known pre-existing 55-failure baseline, zero regressions.
This commit is contained in:
Vance Ingalls
2026-07-14 16:28:33 -07:00
parent 1deb0dc970
commit 539e027b60
6 changed files with 179 additions and 28 deletions
@@ -138,7 +138,11 @@ export function ColorGradingSection({
assets: string[]; assets: string[];
previewIframeRef?: RefObject<HTMLIFrameElement | null>; previewIframeRef?: RefObject<HTMLIFrameElement | null>;
onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>; onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>; onSetAttributeLive: (
attr: string,
value: string | null,
onSettled?: (ok: boolean) => void,
) => void | Promise<void>;
onApplyScope?: ( onApplyScope?: (
scope: "source-file" | "project", scope: "source-file" | "project",
value: string | null, value: string | null,
@@ -33,7 +33,11 @@ export interface PropertyPanelProps {
onUngroup?: () => void; onUngroup?: () => void;
onSetStyle: (prop: string, value: string) => void | Promise<void>; onSetStyle: (prop: string, value: string) => void | Promise<void>;
onSetAttribute: (attr: string, value: string) => void | Promise<void>; onSetAttribute: (attr: string, value: string) => void | Promise<void>;
onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>; onSetAttributeLive: (
attr: string,
value: string | null,
onSettled?: (ok: boolean) => void,
) => void | Promise<void>;
onApplyColorGradingScope?: ( onApplyColorGradingScope?: (
scope: "source-file" | "project", scope: "source-file" | "project",
value: string | null, value: string | null,
@@ -129,7 +129,37 @@ describe("useColorGradingController", () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it("reverts to the last confirmed-good grading when a persist rejects", async () => { it("reverts to the last confirmed-good grading via the real onSettled(false) signal (matches runDomEditCommit, which never rejects)", async () => {
// The actual Studio commit runner (runDomEditCommit) catches persist
// failures internally and always resolves — it reports outcome only
// through the onSettled callback passed as the 3rd argument. A mock
// that only rejects would validate a path the real callback never takes.
vi.useFakeTimers();
const onSetAttributeLive = vi.fn(
(_attr: string, _value: string | null, onSettled?: (ok: boolean) => void) => {
onSettled?.(false);
return Promise.resolve();
},
);
const { root, getState } = renderHook(onSetAttributeLive);
act(() => {
getState().commitColorGrading(freshPopGrading());
});
expect(getState().grading.preset).toBe("fresh-pop");
act(() => {
vi.advanceTimersByTime(400);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(getState().grading.preset).toBe("neutral");
expect(getState().runtimeStatus.state).toBe("unavailable");
act(() => root.unmount());
vi.useRealTimers();
});
it("reverts to the last confirmed-good grading when a persist rejects (fallback for a non-onSettled implementation)", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const onSetAttributeLive = vi.fn().mockRejectedValue(new Error("disk full")); const onSetAttributeLive = vi.fn().mockRejectedValue(new Error("disk full"));
const { root, getState } = renderHook(onSetAttributeLive); const { root, getState } = renderHook(onSetAttributeLive);
@@ -153,6 +183,54 @@ describe("useColorGradingController", () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it("a stale in-flight persist result does not touch state after selection has moved on to a THIRD element", async () => {
vi.useFakeTimers();
let resolveA: (() => void) | undefined;
let capturedOnSettledA: ((ok: boolean) => void) | undefined;
const onSetAttributeLive = vi.fn(
(_attr: string, _value: string | null, onSettled?: (ok: boolean) => void) => {
capturedOnSettledA = onSettled;
return new Promise<void>((resolve) => {
resolveA = resolve;
});
},
);
const { root, getState, rerenderWithElement } = renderHook(
onSetAttributeLive,
makeElement({ id: "s1-bg" }),
);
act(() => {
getState().commitColorGrading(freshPopGrading());
});
// Let the debounce fire while still on s1-bg — the persist call is now
// genuinely in flight (its promise won't settle until resolveA() below).
act(() => {
vi.advanceTimersByTime(400);
});
expect(onSetAttributeLive).toHaveBeenCalledTimes(1);
// Selection moves twice more while s1-bg's persist is still pending.
rerenderWithElement(makeElement({ id: "s2-bg" }));
rerenderWithElement(makeElement({ id: "s3-bg" }));
expect(getState().grading.preset).toBe("neutral"); // s3-bg's own fresh state
// NOW the stale s1-bg persist finally settles as a failure.
act(() => {
capturedOnSettledA?.(false);
resolveA?.();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
// s3-bg's state must be untouched by a result that belongs to s1-bg.
expect(getState().grading.preset).toBe("neutral");
expect(getState().runtimeStatus.state).not.toBe("unavailable");
act(() => root.unmount());
vi.useRealTimers();
});
it("resetGrading returns to the neutral preset", () => { it("resetGrading returns to the neutral preset", () => {
const { root, getState } = renderHook(vi.fn()); const { root, getState } = renderHook(vi.fn());
act(() => { act(() => {
@@ -168,7 +168,11 @@ export function useColorGradingController({
projectId: string; projectId: string;
element: DomEditSelection; element: DomEditSelection;
previewIframeRef?: RefObject<HTMLIFrameElement | null>; previewIframeRef?: RefObject<HTMLIFrameElement | null>;
onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>; onSetAttributeLive: (
attr: string,
value: string | null,
onSettled?: (ok: boolean) => void,
) => void | Promise<void>;
onApplyScope?: ( onApplyScope?: (
scope: "source-file" | "project", scope: "source-file" | "project",
value: string | null, value: string | null,
@@ -190,6 +194,12 @@ export function useColorGradingController({
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingPersistValueRef = useRef<string | null | undefined>(undefined); const pendingPersistValueRef = useRef<string | null | undefined>(undefined);
const pendingPersistGradingRef = useRef<NormalizedHfColorGrading | null>(null); const pendingPersistGradingRef = useRef<NormalizedHfColorGrading | null>(null);
// Populated (pure ref write only) during the render-phase identity-change
// reset below; the actual write happens in an effect, never during render.
const queuedOutgoingFlushRef = useRef<{
setAttributeLive: typeof onSetAttributeLive;
value: string | null;
} | null>(null);
// The last grading value actually confirmed saved — distinct from `grading` // The last grading value actually confirmed saved — distinct from `grading`
// (the optimistic value shown immediately on commit). A rejected persist // (the optimistic value shown immediately on commit). A rejected persist
// reverts to this instead of leaving the UI permanently showing a value // reverts to this instead of leaving the UI permanently showing a value
@@ -216,8 +226,11 @@ export function useColorGradingController({
// Without this, switching selection reuses the previous element's grading/ // Without this, switching selection reuses the previous element's grading/
// compare/mediaMetadata state and can commit stale pending work onto the // compare/mediaMetadata state and can commit stale pending work onto the
// new target. Adjusting state during render (comparing against a ref) is // new target. Adjusting state during render (comparing against a ref) is
// React's documented pattern for this — it resolves in the same render // React's documented pattern for STATE updates specifically — resolving in
// pass instead of flashing the stale state for one frame via useEffect. // the same render pass instead of flashing stale state for one frame. It
// does NOT license side effects: only pure ref/state writes happen in this
// block. The actual outgoing-element flush is enqueued here and performed
// in the effect below, after commit.
const identityKey = selectionIdentityKey(element); const identityKey = selectionIdentityKey(element);
const identityKeyRef = useRef(identityKey); const identityKeyRef = useRef(identityKey);
if (identityKeyRef.current !== identityKey) { if (identityKeyRef.current !== identityKey) {
@@ -231,9 +244,10 @@ export function useColorGradingController({
// the user just changed; targeting it at the callback bound to the OLD // the user just changed; targeting it at the callback bound to the OLD
// selection (captured above) keeps it from landing on the new element. // selection (captured above) keeps it from landing on the new element.
if (pendingPersistValueRef.current !== undefined) { if (pendingPersistValueRef.current !== undefined) {
trackStudioPendingEdit( queuedOutgoingFlushRef.current = {
previousOnSetAttributeLive(COLOR_GRADING_DATA_KEY, pendingPersistValueRef.current), setAttributeLive: previousOnSetAttributeLive,
); value: pendingPersistValueRef.current,
};
} }
pendingPersistValueRef.current = undefined; pendingPersistValueRef.current = undefined;
pendingPersistGradingRef.current = null; pendingPersistGradingRef.current = null;
@@ -251,6 +265,18 @@ export function useColorGradingController({
setMediaMetadata(null); setMediaMetadata(null);
} }
// Performs the outgoing-element flush queued above — deliberately in an
// effect (post-commit), not inline in the render-phase block, since
// writing to disk is a real side effect and must not run during render
// (React may call render more than once per commit without this code ever
// becoming visible).
useEffect(() => {
const queued = queuedOutgoingFlushRef.current;
if (!queued) return;
queuedOutgoingFlushRef.current = null;
trackStudioPendingEdit(queued.setAttributeLive(COLOR_GRADING_DATA_KEY, queued.value));
}, [identityKey]);
const target = useMemo( const target = useMemo(
(): HfColorGradingTarget => ({ (): HfColorGradingTarget => ({
id: element.id ?? null, id: element.id ?? null,
@@ -324,24 +350,44 @@ export function useColorGradingController({
}, [refreshRuntimeStatus]); }, [refreshRuntimeStatus]);
const persistColorGradingValue = useCallback( const persistColorGradingValue = useCallback(
(value: string | null, attemptedGrading: NormalizedHfColorGrading) => { (
const result = onSetAttributeLiveRef.current(COLOR_GRADING_DATA_KEY, value ?? null); value: string | null,
attemptedGrading: NormalizedHfColorGrading,
attemptIdentityKey: string,
) => {
// Selection may move on to a different element while this is in
// flight — the identity-reset block already gave THAT element its own
// confirmedGradingRef baseline, so a result arriving for an element
// we've left must not touch its state.
const applySettled = (ok: boolean) => {
if (identityKeyRef.current !== attemptIdentityKey) return;
if (ok) {
confirmedGradingRef.current = attemptedGrading;
return;
}
// Persist failed — the optimistic grading was never actually saved.
// Revert to the last confirmed-good value instead of leaving the
// control showing an unsaved state as if it succeeded.
const reverted = confirmedGradingRef.current;
latestGradingRef.current = reverted;
setGrading(reverted);
setRuntimeStatus({ state: "unavailable", message: "Save failed — reverted" });
};
// `onSettled` is the real signal — the underlying commit runner
// (runDomEditCommit) intentionally swallows persist failures so a
// caller `await`-ing this promise never sees a rejection; a rejection
// handler here alone would be dead code against the actual Studio
// callback. The `.catch` below is a fallback for any OTHER
// implementation of onSetAttributeLive that rejects instead.
const result = onSetAttributeLiveRef.current(
COLOR_GRADING_DATA_KEY,
value ?? null,
applySettled,
);
return trackStudioPendingEdit( return trackStudioPendingEdit(
Promise.resolve(result).then( Promise.resolve(result).then(
() => { () => undefined,
confirmedGradingRef.current = attemptedGrading; () => applySettled(false),
},
() => {
// Persist failed — the optimistic grading was never actually
// saved. Revert to the last confirmed-good value instead of
// leaving the control showing an unsaved state as if it succeeded.
// Handled here (not rethrown) — nothing downstream awaits this
// promise's rejection; callers fire it with `void`.
const reverted = confirmedGradingRef.current;
latestGradingRef.current = reverted;
setGrading(reverted);
setRuntimeStatus({ state: "unavailable", message: "Save failed — reverted" });
},
), ),
); );
}, },
@@ -358,7 +404,7 @@ export function useColorGradingController({
const attemptedGrading = pendingPersistGradingRef.current ?? latestGradingRef.current; const attemptedGrading = pendingPersistGradingRef.current ?? latestGradingRef.current;
pendingPersistValueRef.current = undefined; pendingPersistValueRef.current = undefined;
pendingPersistGradingRef.current = null; pendingPersistGradingRef.current = null;
return persistColorGradingValue(value, attemptedGrading); return persistColorGradingValue(value, attemptedGrading, identityKeyRef.current);
}, [persistColorGradingValue]); }, [persistColorGradingValue]);
useEffect(() => addStudioPendingEditFlushListener(flushPendingPersist), [flushPendingPersist]); useEffect(() => addStudioPendingEditFlushListener(flushPendingPersist), [flushPendingPersist]);
@@ -444,13 +490,18 @@ export function useColorGradingController({
? serializeHfColorGrading(nextGrading) ? serializeHfColorGrading(nextGrading)
: null; : null;
pendingPersistGradingRef.current = nextGrading; pendingPersistGradingRef.current = nextGrading;
// Captured now (edit time), not read fresh inside the timer — the
// timer fires 350ms later and may run after selection has already
// moved on, at which point identityKeyRef.current would no longer
// describe the element this edit was actually made for.
const attemptIdentityKey = identityKeyRef.current;
persistTimerRef.current = setTimeout(() => { persistTimerRef.current = setTimeout(() => {
const value = pendingPersistValueRef.current; const value = pendingPersistValueRef.current;
const attemptedGrading = pendingPersistGradingRef.current ?? nextGrading; const attemptedGrading = pendingPersistGradingRef.current ?? nextGrading;
pendingPersistValueRef.current = undefined; pendingPersistValueRef.current = undefined;
pendingPersistGradingRef.current = null; pendingPersistGradingRef.current = null;
persistTimerRef.current = null; persistTimerRef.current = null;
void persistColorGradingValue(value ?? null, attemptedGrading); void persistColorGradingValue(value ?? null, attemptedGrading, attemptIdentityKey);
}, 350); }, 350);
}, },
[persistColorGradingValue, postColorGrading, postCompare, scheduleRuntimeStatusRefresh], [persistColorGradingValue, postColorGrading, postCompare, scheduleRuntimeStatusRefresh],
@@ -7,6 +7,15 @@ interface DomEditCommitRunnerConfig {
onError: (error: unknown) => void; onError: (error: unknown) => void;
shouldResync: () => boolean; shouldResync: () => boolean;
resync: () => void | Promise<void>; resync: () => void | Promise<void>;
/**
* Reports success/failure without changing this function's own resolve-
* always contract — `persist` failures are handled here (revert + onError)
* and never rethrown, so callers awaiting `runDomEditCommit` can't observe
* failure via rejection. A caller that needs to react to a specific
* commit's outcome (e.g. reverting its OWN optimistic state) can pass this
* instead of relying on a rejection that will never come.
*/
onSettled?: (ok: boolean) => void;
} }
interface CommitVersionRef { interface CommitVersionRef {
@@ -34,11 +43,13 @@ export async function runDomEditCommit(config: DomEditCommitRunnerConfig): Promi
try { try {
await config.persist(); await config.persist();
config.onSettled?.(true);
} catch (error) { } catch (error) {
if (config.shouldRevert(error)) { if (config.shouldRevert(error)) {
config.revert(); config.revert();
} }
config.onError(error); config.onError(error);
config.onSettled?.(false);
} }
if (!config.shouldResync()) return; if (!config.shouldResync()) return;
@@ -25,6 +25,7 @@ interface DataAttributeCommitOptions {
coalescePrefix: string; coalescePrefix: string;
skipRefresh: boolean; skipRefresh: boolean;
refreshAfter?: boolean; refreshAfter?: boolean;
onSettled?: (ok: boolean) => void;
} }
function resolveFullAttrName(attr: string, prefixData: boolean | undefined): string { function resolveFullAttrName(attr: string, prefixData: boolean | undefined): string {
@@ -128,6 +129,7 @@ export function useDomEditAttributeCommits({
onError: (error) => reportDomEditPersistFailure(domEditSelection, [op], error, showToast), onError: (error) => reportDomEditPersistFailure(domEditSelection, [op], error, showToast),
shouldResync: () => isLatestCommit() && !!options.refreshAfter, shouldResync: () => isLatestCommit() && !!options.refreshAfter,
resync: () => refreshDomEditSelectionFromPreview(domEditSelection), resync: () => refreshDomEditSelectionFromPreview(domEditSelection),
onSettled: options.onSettled,
}); });
}, },
[ [
@@ -153,11 +155,12 @@ export function useDomEditAttributeCommits({
); );
const handleDomAttributeLiveCommit = useCallback( const handleDomAttributeLiveCommit = useCallback(
async (attr: string, value: string | null) => { async (attr: string, value: string | null, onSettled?: (ok: boolean) => void) => {
await commitDataAttribute(attr, value, { await commitDataAttribute(attr, value, {
label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`, label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`,
coalescePrefix: "attr-live", coalescePrefix: "attr-live",
skipRefresh: true, skipRefresh: true,
onSettled,
}); });
}, },
[commitDataAttribute], [commitDataAttribute],