fix(studio): resolve 8 confirmed adversarial-review findings across the flat-inspector stack#2416
Conversation
miguel-heygen
left a comment
There was a problem hiding this comment.
Reviewed at 267cdfce1661b43467d1d0606b43f3629f120cc2, treating this tip fix PR as the authoritative cumulative resolution for the flat-inspector stack.
Blocking findings
-
[BLOCKER] Selection identity still omits the source file.
propertyPanelHelpers.ts:37-45buildsselectionIdentityKeyfrom onlyid,hfId,selector, andselectorIndex, while the canonical edit-target key includessourceFile(domEditingLayers.ts:564-573).useColorGradingController.ts:200-229now depends on the incomplete key to decide whether to flush/reset all per-element state. Two elements in different composition source files with the same local identity therefore reuse grading, compare, metadata, and pending state. IncludesourceFile(preferably reuse the canonical key) and add a cross-source collision regression. -
[BLOCKER] Changing selection silently discards a pending Grade edit. In
useColorGradingController.ts:211-218, an identity change clears the 350ms persistence timer and setspendingPersistValueRef.current = undefined. The accompanying test explicitly expects no write after this transition. That prevents stale work from hitting the new element, but loses the user's last edit to the old element. Flush against a captured old target before switching (or persist each edit through an identity-bound queue) and test that selecting away inside the debounce window saves the old element without touching the new one. -
[BLOCKER] Persistence rejection still cannot reconcile optimistic slider/controller state.
FlatSliderownsdraftand only resynchronizes when the scalarvaluedependency changes (propertyPanelFlatPrimitives.tsx:280-308). If a commit rejects while the controlled value remains the original value, that effect never reruns, so the knob/ARIA value stays at the unsaved attempt. Grade has the same wider form:commitColorGradingupdates controller/runtime state before its fire-and-forget persist, while the persistence seam rolls back only the document. Add an acknowledged failure/revision path that restores the authoritative value and pin rejected-write behavior. -
[BLOCKER] Disabled Style selects still mutate through Reset.
FlatSelectRowdisables only the<select>(propertyPanelFlatPrimitives.tsx:503-535); its explicit-custom reset button remains enabled and callsonReset. Style supplies bothdisabled={!canEditStyles}and mutating reset callbacks for Shadow, Blend, Overflow, and Mask, so a locked/uneditable target can still be changed. Propagatedisabledto the reset button and guard the callback; add a regression.
Important follow-ups
FlatSelectRowcomboboxes still have no accessible name: the visible label is a sibling<span>, while the wrapping<label>contains only the select and chevron. Associate them withhtmlFor/idoraria-label.FlatSliderhandlespointercancelbut not unexpectedlostpointercapture; capture loss can leavedraggingReftrue and suppress controlled-value synchronization indefinitely.- The PR body is still the untouched template despite a +550/−142 behavioral fix. Replace it with the actual eight claimed fixes, stack role, and test plan before merge.
Verified strengths and scope
- Audited all 11 files in this PR and the cumulative caller/contract sites from #2225 through #2416; historical stack code outside those targeted seams was trusted only where patch-equivalence had already been established.
- The tip correctly fixes custom select value representation, slider keyboard/touch behavior, stale Grade callback capture, unmount flushing, compare-listener cleanup, logical alignment preservation, transient metadata-cache poisoning, and constrained-height scrolling.
- Focused local verification passed: 51/51 tests across
propertyPanelFlatPrimitives,propertyPanelFlatTextSection, anduseColorGradingController. GitHub preview/player/regression checks are green/skipped, but Graphite mergeability is still pending because the downstack remains open.
Verdict: REQUEST CHANGES
Reasoning: A tip fix PR is an acceptable resolution strategy, but this tip still loses pending Grade edits on selection change, uses a cross-source-colliding identity key, cannot roll back optimistic UI after rejected persistence, and permits mutations through disabled select resets. Resolve those cumulative-state blockers and complete the PR description before approval.
— Deepwork
…review Fixes the Deepwork tip re-review's four remaining blockers plus its additive findings: - selectionIdentityKey: add sourceFile as a 5th identity component. The same local id/selector can legitimately recur across different composition files (host vs. an inlined sub-composition, or two unrelated sub-comps) — without sourceFile, those collided onto the same identity key and reused stale controller state across a selection change that should have reset it. - useColorGradingController: flush (not discard) a pending Grade edit when selection changes before the 350ms debounce fires. The prior fix correctly stopped it from landing on the WRONG (new) target, but cancelling outright silently dropped the user's in-flight edit instead of writing it to the element it was authored for — using the onSetAttributeLive closure captured for the outgoing render, which (via commitDataAttribute's own useCallback deps) is still bound to the outgoing selection. - useColorGradingController: revert to the last confirmed-good grading when a persist rejects, instead of leaving the optimistic (never-actually- saved) value showing indefinitely. Tracks a separate confirmedGradingRef, updated only on a successful persist. - FlatSelectRow: disable the reset button when the row itself is disabled (it previously ignored disabled entirely, same class of bug as the FlatSlider reset button fixed earlier) and give the underlying <select> an aria-label from the row's label text. - FlatSlider: handle lostpointercapture the same as pointercancel — capture can be lost without either firing first (another element steals it, or the browser reclaims it for a scroll/touch gesture), which previously left the dragging flag stuck and the knob permanently unable to sync to external value changes. New regression tests for all of the above; full studio suite still at the known pre-existing baseline (55 failures unrelated to this stack).
|
All four cumulative blockers fixed at
Selecting away inside the 350ms Grade window discards the pending edit — you're right that the previous fix over-corrected. It now flushes instead: the pending value writes through the Rejected persistence can't restore optimistic Grade state — added a Disabled Style selects still mutate through Reset — Also addressed the two flagged (non-blocking) items:
New regression tests for all of the above (12 new/updated tests across Not touched: the PR template and the Windows install-lock concurrency tests flagged on #2120 — those are pre-existing/unrelated to this stack's changes, happy to take a separate pass if you want them addressed here too. |
miguel-heygen
left a comment
There was a problem hiding this comment.
Reviewed the exact head 6f40e03a15bacf4c9e83a45237374bec80c57b3d, including the full 12-file cumulative diff (+742/-153) and the five-file R1→R2 delta (+196/-15). The sourceFile state key, disabled select reset, lostpointercapture handler, and single-transition pending-edit test are useful corrections. The remaining failures are at the boundaries the new tests mock away.
Blocking findings
-
[P1] The Grade rollback cannot observe a real Studio persistence failure.
useColorGradingController.ts:326-344treats rejection fromonSetAttributeLiveas its failure signal. Production supplieshandleDomAttributeLiveCommit(useDomEditAttributeCommits.ts:155-163), which awaitscommitDataAttribute;runDomEditCommitcatches persistence errors and returns normally (domEditCommitRunner.ts:31-45). On a disk/API failure the controller therefore executes the success branch atuseColorGradingController.ts:330-333, marks the rejected optimistic grade confirmed, and never shows/reverts through the new failure branch.useColorGradingController.test.ts:132-153injects a directly rejecting mock, so it proves a contract the real adapter does not provide. Please propagate an explicit success/failure result (or rejection) across the production seam and test through that adapter. -
[P1] Cross-source identity is fixed for React state but not for the Grade runtime target.
propertyPanelHelpers.ts:40-49now correctly distinguishes identical local targets bysourceFile, but the target built atuseColorGradingController.ts:254-262still carries onlyhfId/id/selector/selectorIndex;HfColorGradingTargethas no source discriminator (packages/core/src/colorGrading.ts:94-99). Runtime resolution takes the first global matchingdata-hf-id/ID (packages/core/src/runtime/colorGrading.ts:1014-1043). Withsub-comp-a.html#bgandsub-comp-b.html#bg, persistence can target B through the source-bound callback while live preview/status/compare operate on A. The new test only checks hook-state reset and has no iframe/runtime assertion. The runtime contract needs the same source identity (or another unambiguous rendered-instance key) plus a negative collision test. -
[P1] Selection-change flushing performs an external persistence side effect during render. The identity branch at
useColorGradingController.ts:223-237callspreviousOnSetAttributeLive(...)synchronously while React is rendering. StrictMode replay, an interrupted render, or an abandoned transition can duplicate that write or persist an edit for a selection change that never commits. The new test (useColorGradingController.test.ts:204-234) is not under StrictMode and uses one stable mock callback, so it cannot exercise replay or the outgoing-target closure. Move the flush to a commit-phase lifecycle/event boundary and make it idempotent. -
[P1] Async Grade completion state is neither selection-scoped nor latest-write-wins. Every completion mutates the shared
confirmedGradingRef(and rejection mutates current UI) atuseColorGradingController.ts:326-344. A save for element A can settle after selection moved to B and overwrite B's confirmed baseline; two saves for one element can settle out of order and regress the baseline to the older value. A later failure then reverts the current control to the wrong element/value. Capture selection identity plus an attempt/version token and ignore stale completions; add deferred-promise tests for selection changes and out-of-order resolve/reject.
Important follow-ups in this delta
- The accessible-name test exercises
FlatSelectRow(label="Shadow"), but the real Grade preset passeslabel=""(propertyPanelFlatColorGradingSection.tsx:311-322), and the LUT select at:359-374is also unnamed.aria-label={label || undefined}atpropertyPanelFlatPrimitives.tsx:515-520does not fix those production controls. - The new capture-loss test orders loss before the prop update. The hostile order is drag → controlled
valuechanges while synchronization is suppressed → capture loss.onLostPointerCaptureonly clearsdraggingRef, so with no later prop change the draft stays stale and the next keyboard action can overwrite the newer value.
Verification
- Focused exact-head tests: 3 files, 55/55 passed (
useColorGradingController,propertyPanelFlatPrimitives,propertyPanelFlatTextSection). - GitHub checks are green/skipped for Preview parity, preview regression, regression, and player perf; Graphite mergeability remains pending because the downstack is open. The PR is mergeable but
UNSTABLEand still has the prior changes-requested gate. - Audited: all five files in the R1→R2 semantic delta and their production caller/runtime contracts. Trusting from the prior full pass: unchanged portions of the other seven cumulative-diff files.
Verdict: REQUEST CHANGES
Reasoning: Several local fixes are correct, but persistence failure handling is disconnected from the real callback contract, source identity still diverges between persistence and runtime preview, and render/async races can write or restore the wrong Grade state. These are user-visible correctness failures on the exact boundaries this fix PR claims to close.
— Deepwork
…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.
|
Two of three fixed at Grade rollback can't fire through the real Studio callback — confirmed: Selection flushing persists during React render — correct, and fixed: the render-phase identity-reset block now only does a pure ref write (enqueuing what needs to flush); a new Async completion state not selection/version-scoped — fixed: both Not fixed — explaining instead: the runtime Grade target ( Full studio suite still at the known pre-existing 55-failure baseline, zero regressions. |
miguel-heygen
left a comment
There was a problem hiding this comment.
Reviewed exact head 34979a039ff3c23e8b2011c5447297b4920d3c68, including the authoritative 16-file cumulative diff (+897/−157), the R2→R3 delta, production persistence adapter, runtime target resolver, and hostile async/render orders.
The real Studio failure signal is now correctly threaded through onSettled, and the cross-source React-state key, disabled reset, and basic capture-loss fixes are sound. Three correctness gaps remain:
-
[BLOCKER] Same-element writes are still not latest-write-wins.
packages/studio/src/components/editor/useColorGradingController.ts:362-374gates completion only by selection identity, not attempt/version. A2 may succeed and then a late A1 success replaces its confirmed baseline; a stale A1 failure can also revert optimistic A2. The adapter computes latestness atpackages/studio/src/hooks/useDomEditAttributeCommits.ts:92-96, butpackages/studio/src/hooks/domEditCommitRunner.ts:44-52reports every completion without that information. Add an attempt token/latestness gate and deferred same-identity resolve/reject tests. -
[BLOCKER] Selection change still mutates persistence state during render.
packages/studio/src/components/editor/useColorGradingController.ts:236-253cancels the timer and consumes the pending value during render. If that render is interrupted or abandoned, the effect at:273-278never flushes it, so the outgoing edit is lost. Stage and finalize the transition only after commit. -
[BLOCKER, carried forward] Runtime and persistence identities still diverge. The target at
packages/studio/src/components/editor/useColorGradingController.ts:280-287lacks source/instance identity, and core globally takes the first match atpackages/core/src/runtime/colorGrading.ts:1030-1043. Cross-source duplicate local targets can therefore preview one element and persist another.
Important carryovers: the real Grade preset and LUT selects remain unnamed (packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx:313-322,359-374), and capture loss does not resynchronize a controlled value suppressed during drag (packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx:304-308,407-414). The PR body also remains the untouched template for this cumulative behavioral fix.
Focused verification: 57/57 tests passed; Studio typecheck passed. GitHub checks are green/skipped except the pending Graphite stack gate.
Verdict: REQUEST CHANGES
Reasoning: Failure propagation now reaches the controller, but same-target out-of-order completions, abandoned-render data loss, and unresolved runtime/persistence identity divergence can still restore, drop, or preview the wrong Grade value.
— Deepwork
…ect cleanup not render Fixes three of the adversarial findings from the third #2416 tip re-review: - Grade rollback was identity-scoped but not attempt-scoped: two edits on the SAME element (e.g. drag Exposure, then Contrast, before Exposure's persist settles) could have the earlier edit's late completion stamp confirmedGradingRef with its now-superseded value, or revert `grading` out from under the newer optimistic edit. Added a monotonic per-commit version via the existing bumpDomEditCommitVersion primitive (the same one the DOM-attribute commit runner uses for the identical race) — persistColorGradingValue now checks both identity AND "is this still the latest attempt for this element" before applying any effect. - The render-phase identity-reset block consumed shared mutable state (clearing the pending-persist timer, reading and nulling pendingPersistValueRef) directly during render. Adjusting STATE during render this way is React's documented pattern and safe to repeat, but consuming a ref this way is not: if React discarded/interrupted that specific render before it committed, the timer would already be cancelled and the pending value already nulled, with no corresponding effect ever running to compensate, silently losing the edit. Replaced with the idiomatic pattern for "clean up a per-identity resource when it changes" — a useEffect keyed on identityKey whose CLEANUP performs the cancellation/flush. A cleanup only ever runs for the effect instance that actually committed, closing the gap entirely. The render-phase block now only performs pure, idempotent state resets. - FlatSelectRow's Preset row passes label="" (the visible "Preset" text is a sibling span, to avoid rendering it twice) which left the underlying <select> with no accessible name at all. Added a dedicated `ariaLabel` prop, distinct from the visible `label`, so a caller can supply a name without a duplicate visible label. Also hardened FlatSlider's lostpointercapture handling: it now resyncs the draft directly from a latestValueRef immediately, instead of only clearing the dragging flag and waiting for the separate [value]-keyed effect to notice — closing a narrow ordering gap where a value change arriving while still dragging, followed by capture loss with no further render, could otherwise leave the knob stuck. propertyPanelFlatPrimitives.tsx crossed the 600-line file-size gate after these changes; extracted FlatToggle (and its tests) into their own files, matching the FlatMaskInsetRows precedent from an earlier commit in this stack. New/updated regression tests: same-element version race, Preset select's aria-label. Full studio suite still at the known pre-existing 55-failure baseline, zero regressions.
|
Fixed at `d6a40c38b`: Same-element writes identity-scoped but not attempt-scoped — real bug: two edits on the SAME element (drag Exposure, then Contrast, before Exposure's persist settles) could have the earlier edit's late completion stamp `confirmedGradingRef` with its now-superseded value, or revert `grading` out from under the newer optimistic edit. Added a monotonic per-commit version via the existing `bumpDomEditCommitVersion` primitive — the same one the DOM-attribute commit runner already uses for the identical same-target race. `persistColorGradingValue` now checks identity AND "is this still the latest attempt for this element" before applying anything. New test: commits fresh-pop, lets its persist go in flight, commits natural-lift on the SAME element, then settles fresh-pop's stale result as a failure — asserts `grading` stays at natural-lift. Selection render still cancels/consumes pending persistence before commit — also correct, and the deeper one: the render-phase block was consuming a REF (clearing the timer, nulling `pendingPersistValueRef`) directly during render. Adjusting STATE during render this way is sanctioned and safe to repeat; consuming shared mutable ref state is not — a discarded/interrupted render would have already done the consumption with no corresponding effect ever running to compensate. Replaced with the idiomatic React pattern for this exact situation: a `useEffect` keyed on `identityKey` whose cleanup does the cancel/flush. A cleanup only ever runs for the effect instance that actually committed, so there's no window left for silent loss. The render-phase block now only does pure, idempotent state resets. Unnamed production Grade selects — the Preset row passes `label=""` (its visible text is a sibling span, to avoid duplicating it) which left the `` with no accessible name via my earlier `aria-label={label}` fix. Added a dedicated `ariaLabel` prop to `FlatSelectRow`, distinct from the visible `label`, and wired it at the Preset call site. Hostile value-update-before-lostpointercapture order — hardened: `onLostPointerCapture` now resyncs the draft directly from a `latestValueRef` immediately, instead of only clearing the dragging flag and relying on the separate `[value]`-keyed effect to notice on some later render. New test reproduces the exact race: value changes to a new prop while still dragging (effect correctly skips syncing), then capture is lost with no further render — draft still correctly resyncs. Runtime Grade target source discriminator — unchanged from my last reply; still a deliberate deferral (protocol-level, shared across core/player/studio, narrow risk since `hfId` is checked first and is normally unique). `propertyPanelFlatPrimitives.tsx` crossed the 600-line file-size gate after these changes — extracted `FlatToggle` (+ its tests) into its own file, same pattern as an earlier commit in this stack. Full studio suite still at the known pre-existing 55-failure baseline, zero regressions. |
miguel-heygen
left a comment
There was a problem hiding this comment.
Re-review at d6a40c38b4a01e2c206b3ee1cc8801a61b8e6627.
The R4 changes close four concrete findings cleanly: the normal debounced persistence path now carries a monotonic per-commit version; pending persistence is consumed only from a committed identity effect's cleanup; Preset has an explicit accessible name; and lostpointercapture resynchronizes from latestValueRef. The new race tests are useful, and the focused Grade/primitive/toggle suite is green (87/87) with Studio typecheck green.
Blocking findings
-
[blocker] Normal pointer release now runs the "unexpected capture loss" rollback —
packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx:391-425releasePointerCapture()on the ordinarypointeruppath itself causeslostpointercapture. The new handler unconditionally restoresdraftandlastCommittedReffrom the controlled prop. If the parent has not echoed the final commit yet, an ordinary successful release therefore snaps the knob back to the old value; if loss dispatches synchronously, it can cleardraggingRefbefore the rest ofpointerupand skip the final commit. Finalize/clear the drag before releasing capture, and only perform the lost-capture resync when the handler observes a still-active unexpected drag. Add a real pointerdown → pointerup → lostpointercapture test with no immediate parent prop echo. -
[blocker] The committed-effect cleanup still has a wrong-target timer window —
packages/studio/src/components/editor/useColorGradingController.ts:210-214,259-272,381-385,500-516Moving timer cancellation from render to passive-effect cleanup avoids consuming work from a discarded render, but
onSetAttributeLiveRef.currentis still overwritten during every render. If selection B renders while A's 350 ms timer is live, an interrupted render—or simply timer expiry before the passive cleanup—lets A's timer callpersistColorGradingValue, which reads B's callback from the ref and persists A's serialized grade through B's target. The cleanup comment claims the outgoing callback is captured, but the timer path does not capture it. Store the identity-bound persistence callback with the attempt (or make the timer an identity-owned committed resource) and test expiry between B render and the old identity's effect cleanup. -
[blocker] The explicit flush path still bypasses the same-element version guard —
packages/studio/src/components/editor/useColorGradingController.ts:396-410The timer path correctly captures
bumpDomEditCommitVersion(...), butflushPendingPersist()starts the pending write with() => true. That is only latest at the instant the flush begins; it can remain in flight while the mounted editor accepts a newer commit. This is reachable throughwaitForPendingDomEditSaves()(render, frame capture, undo/redo, and scope-copy all invoke the global flush). Sequence: edit A is pending → render/capture flushes A → user makes newer edit B before A settles → A's late success stampsconfirmedGradingRefwith A, or A's late failure reverts the visible B state. The newly added same-element test covers the debounce-timer path, not this direct-flush path. Preserve the captured commit/version with the pending value and use that checker from both timer and explicit flush; pin a deferred flush → newer commit → out-of-order settle regression. -
[blocker, carried with concrete contract evidence] Runtime preview/status still resolves a different identity than persistence —
packages/studio/src/components/editor/useColorGradingController.ts:274-282,packages/core/src/colorGrading.ts:94-99,packages/core/src/runtime/colorGrading.ts:1030-1043Studio already recovers a composition's source file after runtime inlining (
domEditingDom.ts:102-145) andfindElementForSelectionfilters candidates by that source. Grade persistence is therefore source-bound. The Grade runtime target still contains onlyhfId/id/selector/index, and runtime resolution globally returns the first match. Two inlined compositions with the same authoreddata-hf-id, id, or selector can consequently persist to the selected source while preview/status/compare mutate the other rendered instance. R4 intentionally defers this, but the split-brain contract remains on the newly shipped Grade path. Either carry a source/instance discriminator throughHfColorGradingTargetand resolution, or fail closed when the target is ambiguous; add a two-source collision test that asserts both persistence and runtime address the same element.
Important follow-ups
- [important] Old runtime-status timers survive selection changes —
useColorGradingController.ts:330-344. R3 cleared these in the identity-change block; R4 removed that side effect but only clears timers when scheduling again or unmounting. An outgoing target's delayed refresh can run after the new target's immediate refresh and overwrite the panel with stale status. Clear the old identity's timers in the identity effect cleanup and add an A→B fake-timer test. - [important, partial fix] Two Grade selects remain unnamed —
propertyPanelFlatColorGradingSection.tsx:360-375,527-535. Preset is fixed, but Custom LUT and Copy grade to still lack an associated<label>/aria-label(surrounding<span>text does not label a select). Name both and assertgetByRole("combobox", { name: ... })for the production Grade panel. - [nit] PR description remains the untouched template, so the shipped scope, invariants, deliberate deferral, and test plan are not reviewable from the PR body.
Scope and verification
Audited: the complete R3→R4 semantic delta (9 files, +318/−146), the full controller/runtime identity and persistence/flush paths, every Grade select, and the related global-flush callers. Revalidated the cumulative diff/stat (20 files, +1151/−239). Trusting from earlier rounds: unchanged Flat-panel styling/layout surfaces outside these contracts.
Local verification:
bunx vitest run src/components/editor/useColorGradingController.test.ts src/components/editor/propertyPanelFlatPrimitives.test.tsx src/components/editor/propertyPanelFlatTextSection.test.tsx src/components/editor/propertyPanelFlatColorGradingSection.test.tsx src/components/editor/propertyPanelFlatToggle.test.tsx— 87/87 passed.bun run typecheckinpackages/studio— passed.- GitHub at review time: required Actions lanes green/skipped; Graphite mergeability remains pending on the stack.
Verdict: REQUEST CHANGES
Reasoning: R4 improves each claimed area on its tested path, but ordinary pointer release can now roll back a successful drag, selection rendering can redirect an outgoing timer through the new target's callback, explicit flush still defeats the version invariant, and runtime Grade targeting remains source-ambiguous while persistence is source-bound. These are concrete wrong-state/wrong-element paths, so they need to close before this is safe to land.
— Deepwork
…schedule-time callback Fixes the four blockers from the #2416 re-review at head d6a40c3: - FlatSlider's onPointerUp calls releasePointerCapture() explicitly, which fires lostpointercapture SYNCHRONOUSLY in real browsers — the prior unconditional onLostPointerCapture resync ran mid-onPointerUp, flipping draggingRef false before onPointerUp's own check, silently dropping every normal drag-release's final commitDraft(). happy-dom doesn't replicate the synchronous cascade, so this shipped without a failing test. Added an explicitReleaseRef flag set right before each deliberate releasePointerCapture() call so onLostPointerCapture can tell "our own release, caller's logic already handles it" apart from a genuine external capture loss. Added a regression test that monkey-patches releasePointerCapture to reproduce the real-browser ordering. - persistColorGradingValue read onSetAttributeLiveRef.current (reassigned every render) instead of the callback live when the debounced edit was scheduled — a timer for element A firing after a re-render for element B would wrongly call B's callback with A's data. Removed the ref; the callback is now an explicit parameter captured by commitColorGrading's own closure (added to its useCallback deps) and threaded through to persistColorGradingValue and flushPendingPersist. - flushPendingPersist passed () => true as its isLatestAttempt checker, bypassing the per-commit version guard entirely. Now calls bumpDomEditCommitVersion(gradingVersionRef) like a regular debounced commit, so a newer edit landing before the flushed write settles still wins the race. - The selection-identity cleanup effect stopped clearing statusTimersRef during an earlier refactor — stale RUNTIME_STATUS_REFRESH_DELAYS timers for an outgoing element could fire after switching selection and stamp the new element's runtimeStatus with the old element's answer. Restored the clear in the same effect cleanup. Also gave the Custom LUT and "Copy grade to" scope <select> controls aria-labels — both had their visible text in a sibling span/text node, so neither had an accessible name. Full studio suite still at the known pre-existing 55-failure baseline (variablePromoteIntegration, useGsapPropertyDebounce, sdkCutover(Parity), sdkResolverShadow), zero new regressions. Typecheck, oxlint, oxfmt clean.
|
Re-review at All four blockers fixed:
Also fixed the two flagged items:
Full studio suite still at the known pre-existing 55-failure baseline ( |
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at beaf4ffbf6fd2e1b8a543d38cf0d7f3608c1eb7e — R6 delta since my R5 review. R6 introduces dragStartValueRef + activePointerIdRef + a cancelDrag(target) helper, wired into onKeyDown (Escape) and onContextMenu (right-click).
R5 finding verdicts
⚠️ R5 finding 1 —onPointerCancelcancel-path regression: NARROWS BUT PERSISTS. R6 built the exact primitive I proposed (cancelDrag(target)at lines 385-395 doessetDraft(dragStartValueRef.current)+commitDraft(dragStartValueRef.current)inside theexplicitReleaseRef=trueguarded release), and wired it into keyboard cancel + right-click cancel. It's the right primitive. ButonPointerCancel(line 443-449) was NOT updated to call it — the handler still just setsexplicitReleaseRef=true+ releases + relies on… nothing. Browser-fired cancel (system gesture takeover, touch reclaim for scroll, hardware cancel) still landsdraftstuck mid-drag while parentvaluereflects only the last throttled commit. Inline comment below with the trivial fix (cancelDrag(e.currentTarget)).⚠️ R5 finding 2 — missing pointercancel test coverage: PERSISTS. New tests at lines 477 (Escape) + 522 (contextmenu) cover the two cancelDrag call-sites R6 added — good. Neither reproduces the browser-nativepointerdown → pointercancelshape (i.e. no test dispatches apointercancelevent with the same patched-release-to-sync-lostpointercapture pattern from line 433's reentrancy test). Adding a sibling test would fail against R6 and catch the regression above.⚠️ R5 finding 3 —flushPendingPersistconcurrent-mode identity race: PERSISTS.useColorGradingController.ts:427still readsidentityKeyRef.currentat flush time. Compare withcommitColorGradingat line 520 which correctly capturesattemptIdentityKey = identityKeyRef.currentfor the debounced timer path. The flush path needs the same capture-at-commit-time treatment via apendingPersistIdentityRefset at line 512.
Positive notes
- Extraction of
FlatSelectRowinto its own file atpackages/studio/src/components/editor/flatSelectRow.tsxis clean. Component contract preserved. cancelDragis the right primitive shape — atomic, uses the closed-overdragStartValueRef, guards ondraggingRef, checkshasPointerCapturebefore release.- Escape + right-click test coverage is solid — both exercise the full cancel semantics including
defaultPreventedfor contextmenu.
Nits
explicitReleaseRefreset stays one-shot insideonLostPointerCapture(line 458). Same latching-class hazard I noted in R5 nit. Defensive reset inonPointerUp/onPointerCancel/cancelDragafter the release call would close it.
What I didn't verify
- Blocker 4 (runtime source-discriminator) still deferred per PR body.
- The React 18 concurrent-mode identity-race scenario is still theoretical unless Studio uses
useTransition/Suspensearound this hook.
Verdict framing
The cancel primitive is right — three of four wiring sites are correct. Adding cancelDrag(e.currentTarget) in onPointerCancel closes the last one and unblocks the cancel-path regression. Concurrent-mode finding is narrow but real.
| explicitReleaseRef.current = true; | ||
| e.currentTarget.releasePointerCapture(e.pointerId); | ||
| } | ||
| }} |
There was a problem hiding this comment.
🟠 onPointerCancel bypasses the new cancelDrag() — browser-fired cancel still leaves draft mid-drag.
R6 built exactly the right primitive (cancelDrag at lines 385-395: reset draggingRef, release capture with explicitReleaseRef=true, setDraft(dragStartValueRef.current), commitDraft(dragStartValueRef.current)) and wired it into onKeyDown (Escape, line 475) and onContextMenu (right-click, line 490). But onPointerCancel was left with only the release-guard half:
onPointerCancel={(e) => {
draggingRef.current = false;
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
explicitReleaseRef.current = true;
e.currentTarget.releasePointerCapture(e.pointerId);
}
}}The explicitReleaseRef=true short-circuits onLostPointerCapture's reset (line 451-460), and nothing else resyncs draft back to dragStartValueRef. Result — browser-native cancel path:
- User pointerdowns on slider at draft=10, drags → 60. Throttled
commitDrafthas pushedvalueto (say) 50; latest drag position 60 not yet flushed. - Browser fires
pointercancel(touch reclaim for scroll, OS gesture, hardware cancel — anything that's not Escape or contextmenu). explicitReleaseRef=true→onLostPointerCapturereturns early. Nothing resetsdraft.- Visual shows 60, parent's
valueprop stays at 50. Mismatch persists until the[value]effect at line 305 wakes on the next value-prop change.
Fix — swap the body for the shared helper (which does the right thing already):
onPointerCancel={(e) => {
cancelDrag(e.currentTarget);
}}cancelDrag uses activePointerIdRef.current for the release check (identical to e.pointerId here since onPointerDown sets both to the same value), guards on draggingRef (so a stray cancel outside a drag no-ops), and runs the full reset. Symmetric with the keyboard/context-menu cancel paths.
— Rames D Jusso
| ); | ||
| }); | ||
| expect(onCommit).toHaveBeenLastCalledWith(65); | ||
| const contextMenuEvent = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); |
There was a problem hiding this comment.
🟠 Missing browser-native pointercancel test coverage.
The new tests cover Escape (line 477) and contextmenu (line 522) — both cancelDrag call-sites R6 added. Neither covers the third cancel entry point: the browser firing pointercancel directly (system gesture, touch reclaim for scroll, hardware cancel). A sibling test dispatching pointerdown → pointercancel with the same patched releasePointerCapture → sync lostpointercapture pattern from line 433's reentrancy test would fail against R6 (see inline concern on propertyPanelFlatPrimitives.tsx:449) and catch the regression:
it("pointercancel during a drag reverts to the pre-drag value, instead of leaving the last dragged-to position committed", () => {
const onCommit = vi.fn();
const { host, root } = renderInto(
<FlatSlider label="Opacity" value={10} min={0} max={100} tier="explicitCustom" displayValue="10%" onCommit={onCommit} />,
);
const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
if (!track) throw new Error("expected a track element");
Object.defineProperty(track, "getBoundingClientRect", { value: () => ({ left: 0, width: 100, top: 0, height: 20, right: 100, bottom: 20 }) });
const originalRelease = track.releasePointerCapture.bind(track);
track.releasePointerCapture = (pointerId: number) => {
originalRelease(pointerId);
track.dispatchEvent(new Event("lostpointercapture", { bubbles: true }));
};
act(() => track.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: 30, pointerId: 1 })));
onCommit.mockClear();
act(() => track.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: 80, pointerId: 1 })));
act(() => track.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true, clientX: 80, pointerId: 1 })));
expect(onCommit).toHaveBeenLastCalledWith(10);
expect(track.getAttribute("aria-valuenow")).toBe("10");
act(() => root.unmount());
});— Rames D Jusso
| return persistColorGradingValue( | ||
| value, | ||
| attemptedGrading, | ||
| identityKeyRef.current, |
There was a problem hiding this comment.
🟠 flushPendingPersist reads identityKeyRef.current at flush time — capture-at-commit-time is done for the debounce path but not this one.
commitColorGrading at line 520 correctly captures attemptIdentityKey = identityKeyRef.current at commit time and passes it into the debounced persistColorGradingValue call. flushPendingPersist (line 406-431) does not — line 427 reads the live ref at flush time, opening the same identity-race window I noted in R5 under React 18 concurrent mode:
- Commit edit A on element identity 1:
pendingPersistValueRef.current = Aat line 512. - Selection change starts rendering with identityKey=2; render-phase mutation at line 234 flips
identityKeyRef.current = 2. - React discards this render (higher-priority interruption via
useTransition/ Suspense). - Identity-effect cleanup at line 258 never runs —
pendingPersistValueRefstill holds A. - External flush fires (frame capture,
waitForPendingDomEditSaves, undo/redo). Line 427 readsidentityKeyRef.current = 2and passes it asattemptIdentityKey. applySettledat line 370:identityKeyRef.current === attemptIdentityKey→ 2===2 → passes. StampsconfirmedGradingRefwith A's grading under identity 2.
Fix — mirror the debounce path's capture-at-commit-time pattern. Add a sibling ref, populate it alongside pendingPersistValueRef at line 512, read it here:
const pendingPersistIdentityRef = useRef<string | undefined>(undefined);
// At line 512 (in commitColorGrading):
pendingPersistValueRef.current = isHfColorGradingActive(nextGrading) ? serialize(nextGrading) : null;
pendingPersistIdentityRef.current = identityKeyRef.current;
// At line 258 identity-effect cleanup:
pendingPersistIdentityRef.current = undefined;
// At line 427 in flushPendingPersist:
const attemptIdentityKey = pendingPersistIdentityRef.current;
if (attemptIdentityKey === undefined) return undefined;
pendingPersistIdentityRef.current = undefined;
return persistColorGradingValue(value, onSetAttributeLive, attemptIdentityKey, isLatestAttempt);Caveat: only reachable if this hook is rendered inside a useTransition / Suspense boundary that allows discarded renders. Worth confirming which render modes are in scope for the panel — if it's classic mode only, this stays a nit.
— Rames D Jusso
miguel-heygen
left a comment
There was a problem hiding this comment.
Current-head re-review
The R4→R6 work closes the previously reported Escape/right-click path cleanly: cancelDrag restores the pre-drag value through the real commit callback, pointer capture is released, the slider is keyboard operable, and the new tests pin those two user exits. The versioned Grade settle path and edit-time identity capture in the normal debounce callback are also meaningful improvements.
[blocker] Native pointercancel bypasses the new cancellation primitive
propertyPanelFlatPrimitives.tsx:443-449 clears draggingRef and releases capture but never calls cancelDrag. Browser-native cancellation (touch/scroll takeover, system gesture, or device cancellation) therefore leaves the last intermediate draft/leading-edge commit in place instead of reverting to dragStartValueRef, unlike Escape and right-click. Because explicitReleaseRef is set, onLostPointerCapture intentionally skips its resync too. Route this exit through cancelDrag(e.currentTarget) and add the missing pointercancel regression test alongside the Escape/context-menu tests.
[important] Global flush still reads selection identity too late
The normal debounced callback correctly captures attemptIdentityKey at edit time (useColorGradingController.ts:516-536). flushPendingPersist, however, consumes the same pending value and passes identityKeyRef.current at flush time (useColorGradingController.ts:406-430). During a concurrent selection transition, a pending value created for the outgoing element can therefore be persisted/settled under the newly rendered identity. Store the pending identity beside pendingPersistValueRef when the edit is queued and have both timer and flush consume that snapshot; add a selection-change/global-flush test.
Evidence
- Focused Studio tests at
beaf4ffbf6fd2e1b8a543d38cf0d7f3608c1eb7e: 5 files, 91/91 passed (useColorGradingController, flat primitives/text/Grade/toggle). packages/studio:bun run typecheckpassed.- GitHub Actions are green/skipped; only the Graphite stack mergeability check remains in progress.
- Audited: the complete R4→R6 semantic delta, all FlatSlider lifecycle exits and timer cleanup, Grade debounce/flush identity and settle-version paths, and the associated focused tests. Trusted: cumulative visual-only panel decomposition and unchanged renderer internals outside those contracts.
Verdict: REQUEST CHANGES
Reasoning: Escape and right-click cancellation are fixed, but the sibling browser-native cancellation path still commits an intermediate drag, and the global Grade flush still lacks the edit-time identity snapshot already used by the normal timer path.
— Deepwork
… matching, pointercancel revert Fixes real bugs from two independent re-reviews (#2225 @ 65954c3, #2416 @ beaf4ff): - FlatTimingRow's pinRange committed a pinned start+duration range through TWO sequential onSetAttribute calls. Each resolves domEditSelection fresh from current hook state, so a selection change between the two awaits could misdirect the second write at the newly-selected element instead of the one being edited, and a failure of just the second call left the pair half-applied (inconsistent inferred/explicit state). Added commitDataAttributes/handleDomAttributesCommit (mirroring onCommitAnimatedProperties's same-shaped fix for GSAP property batches): one PatchOperation[] persist call against an explicit, caller-supplied selection — not the "current" one — threaded through as the new optional onSetAttributes prop. pinRange uses it when provided, falls back to the old sequential behavior otherwise. - Hide All silently dropped nested sub-composition children: a selection inside a sub-comp with no timeline-store entry of its own resolves to a virtual `sourceFile#domId` key (the fallback branch exists so the expansion hook can later resolve it via clipParentMap), but toggleTimelineElementHidden only searched the RAW store list, which never contains that key. useTimelineElementVisibilityEditing now resolves against useExpandedTimelineElements() instead, matching the track-based toggle's existing approach — the expanded list synthesizes a real, patchable TimelineElement (matching key/domId/sourceFile) for each visible child whenever its host is currently expanded. - Two composition hosts importing the same sub-composition collapsed to the first one: findMatchingTimelineElementId ORed domId/selector/ compositionSrc matches with equal priority in a single per-element scan, so `.find()` could stop at an EARLIER, unrelated host that merely shared the compositionSrc, before the scan ever reached the correct domId/ selector match further down the list. Restructured to try domId, then selector, across the WHOLE list first; compositionSrc-only matching is now a true last resort for when neither identifies a specific element. - FlatSlider's native pointercancel handler (a platform-level gesture abort — scroll/touch takeover, pen leaving range) manually duplicated the pointer-capture release logic instead of calling cancelDrag, so it never reverted to the pre-drag value — leaving whatever intermediate position the pointer last reached committed, unlike the Escape/right-click paths added in the previous round. Now calls cancelDrag directly. - useColorGradingController's flushPendingPersist read identityKeyRef.current fresh at flush time rather than a value snapshotted when the edit was scheduled. Defensive fix: added pendingPersistIdentityRef, set alongside pendingPersistValueRef in commitColorGrading, read by flushPendingPersist instead of the live ref — closes the gap regardless of how unlikely the actual race is given the identity-cleanup effect's existing eager-flush behavior. Two prior findings re-verified as already fixed further up this same Graphite stack (not re-fixed here, per established stack-order handling): metadata-cache negative-caching (267cdfc) and cross-file selectionIdentityKey (6f40e03), both landing after #2225's reviewed head. StudioRightPanel.tsx crossed the 600-line file-size gate after wiring the new onSetAttributes prop through; extracted the inspector split-pane resize handlers (previously inlined) into their own useInspectorSplitResize hook. New regression tests: repeated-composition-host resolution, atomic vs. fallback pinRange commit paths, pointercancel revert. Full studio suite still at the known pre-existing 55-failure baseline, zero new regressions. Typecheck/oxlint/oxfmt clean.
|
Re-checked both findings at head
Also fixed two more confirmed bugs from a separate, concurrent re-review of #2225 (nested sub-composition Hide All dropping virtual keys, and repeated composition hosts collapsing to the first key) — see that PR's thread for details; the fixes landed in this same push since #2416 is the stack tip. Full studio suite still at the known pre-existing 55-failure baseline, zero new regressions. Typecheck/oxlint/oxfmt clean. |
miguel-heygen
left a comment
There was a problem hiding this comment.
Current-head re-review
All findings from my #2225 and #2416 reviews are closed at 832d5be8e2ea0cc2144bb0486a25a0f7601e2ea1:
propertyPanelFlatPrimitives.tsx:441-450routes nativepointercancelthrough the same pre-drag rollback primitive as Escape/right-click; the new test pins value, ARIA state, and capture release.useColorGradingController.ts:195-199,416-440,522-548snapshots the pending edit identity at schedule time and consumes that identity from both debounce and global-flush paths.timelineTrackVisibility.ts:333-374uses expanded timeline rows for virtual sub-composition children.studioHelpers.ts:164-197prevents repeated composition hosts from collapsing to the firstcompositionSrcmatch.propertyPanelFlatMotionSection.tsx:32-51anduseDomEditAttributeCommits.ts:164-260commit inferred start/duration as one logical operation bound to the originating selection, with one persistence/history boundary and rollback of both preview attributes.- The cumulative tip also contains the earlier non-OK metadata retry and source-file identity fixes.
The useInspectorSplitResize extraction preserves the prior pointer-capture behavior while keeping StudioRightPanel.tsx below the size gate.
Verification
- Focused tests: 5 files, 76/76 passed (
FlatTimingRow,FlatSlider, Grade controller, Studio helpers, timeline visibility). - Studio
bun run typecheckpassed. - R6→current delta: 18 files, +424/−86; full delta read and
git diff --checkclean. - Fresh GitHub Actions are green/skipped; only Graphite's expected stack mergeability check remains in progress.
Audited: all 18 files in the R6→current delta and the cumulative contract sites named above. Trusting: unchanged flat-panel styling/layout code previously reviewed in earlier rounds.
Verdict: APPROVE
Reasoning: The current tip closes the remaining cancellation, identity, nested-visibility, repeated-host, and atomic-timing defects with focused regression coverage and clean verification.
— Deepwork
…review Fixes the Deepwork tip re-review's four remaining blockers plus its additive findings: - selectionIdentityKey: add sourceFile as a 5th identity component. The same local id/selector can legitimately recur across different composition files (host vs. an inlined sub-composition, or two unrelated sub-comps) — without sourceFile, those collided onto the same identity key and reused stale controller state across a selection change that should have reset it. - useColorGradingController: flush (not discard) a pending Grade edit when selection changes before the 350ms debounce fires. The prior fix correctly stopped it from landing on the WRONG (new) target, but cancelling outright silently dropped the user's in-flight edit instead of writing it to the element it was authored for — using the onSetAttributeLive closure captured for the outgoing render, which (via commitDataAttribute's own useCallback deps) is still bound to the outgoing selection. - useColorGradingController: revert to the last confirmed-good grading when a persist rejects, instead of leaving the optimistic (never-actually- saved) value showing indefinitely. Tracks a separate confirmedGradingRef, updated only on a successful persist. - FlatSelectRow: disable the reset button when the row itself is disabled (it previously ignored disabled entirely, same class of bug as the FlatSlider reset button fixed earlier) and give the underlying <select> an aria-label from the row's label text. - FlatSlider: handle lostpointercapture the same as pointercancel — capture can be lost without either firing first (another element steals it, or the browser reclaims it for a scroll/touch gesture), which previously left the dragging flag stuck and the knob permanently unable to sync to external value changes. New regression tests for all of the above; full studio suite still at the known pre-existing baseline (55 failures unrelated to this stack).
…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.
…ect cleanup not render Fixes three of the adversarial findings from the third #2416 tip re-review: - Grade rollback was identity-scoped but not attempt-scoped: two edits on the SAME element (e.g. drag Exposure, then Contrast, before Exposure's persist settles) could have the earlier edit's late completion stamp confirmedGradingRef with its now-superseded value, or revert `grading` out from under the newer optimistic edit. Added a monotonic per-commit version via the existing bumpDomEditCommitVersion primitive (the same one the DOM-attribute commit runner uses for the identical race) — persistColorGradingValue now checks both identity AND "is this still the latest attempt for this element" before applying any effect. - The render-phase identity-reset block consumed shared mutable state (clearing the pending-persist timer, reading and nulling pendingPersistValueRef) directly during render. Adjusting STATE during render this way is React's documented pattern and safe to repeat, but consuming a ref this way is not: if React discarded/interrupted that specific render before it committed, the timer would already be cancelled and the pending value already nulled, with no corresponding effect ever running to compensate, silently losing the edit. Replaced with the idiomatic pattern for "clean up a per-identity resource when it changes" — a useEffect keyed on identityKey whose CLEANUP performs the cancellation/flush. A cleanup only ever runs for the effect instance that actually committed, closing the gap entirely. The render-phase block now only performs pure, idempotent state resets. - FlatSelectRow's Preset row passes label="" (the visible "Preset" text is a sibling span, to avoid rendering it twice) which left the underlying <select> with no accessible name at all. Added a dedicated `ariaLabel` prop, distinct from the visible `label`, so a caller can supply a name without a duplicate visible label. Also hardened FlatSlider's lostpointercapture handling: it now resyncs the draft directly from a latestValueRef immediately, instead of only clearing the dragging flag and waiting for the separate [value]-keyed effect to notice — closing a narrow ordering gap where a value change arriving while still dragging, followed by capture loss with no further render, could otherwise leave the knob stuck. propertyPanelFlatPrimitives.tsx crossed the 600-line file-size gate after these changes; extracted FlatToggle (and its tests) into their own files, matching the FlatMaskInsetRows precedent from an earlier commit in this stack. New/updated regression tests: same-element version race, Preset select's aria-label. Full studio suite still at the known pre-existing 55-failure baseline, zero regressions.
832d5be to
fe4d135
Compare
…schedule-time callback Fixes the four blockers from the #2416 re-review at head d6a40c3: - FlatSlider's onPointerUp calls releasePointerCapture() explicitly, which fires lostpointercapture SYNCHRONOUSLY in real browsers — the prior unconditional onLostPointerCapture resync ran mid-onPointerUp, flipping draggingRef false before onPointerUp's own check, silently dropping every normal drag-release's final commitDraft(). happy-dom doesn't replicate the synchronous cascade, so this shipped without a failing test. Added an explicitReleaseRef flag set right before each deliberate releasePointerCapture() call so onLostPointerCapture can tell "our own release, caller's logic already handles it" apart from a genuine external capture loss. Added a regression test that monkey-patches releasePointerCapture to reproduce the real-browser ordering. - persistColorGradingValue read onSetAttributeLiveRef.current (reassigned every render) instead of the callback live when the debounced edit was scheduled — a timer for element A firing after a re-render for element B would wrongly call B's callback with A's data. Removed the ref; the callback is now an explicit parameter captured by commitColorGrading's own closure (added to its useCallback deps) and threaded through to persistColorGradingValue and flushPendingPersist. - flushPendingPersist passed () => true as its isLatestAttempt checker, bypassing the per-commit version guard entirely. Now calls bumpDomEditCommitVersion(gradingVersionRef) like a regular debounced commit, so a newer edit landing before the flushed write settles still wins the race. - The selection-identity cleanup effect stopped clearing statusTimersRef during an earlier refactor — stale RUNTIME_STATUS_REFRESH_DELAYS timers for an outgoing element could fire after switching selection and stamp the new element's runtimeStatus with the old element's answer. Restored the clear in the same effect cleanup. Also gave the Custom LUT and "Copy grade to" scope <select> controls aria-labels — both had their visible text in a sibling span/text node, so neither had an accessible name. Full studio suite still at the known pre-existing 55-failure baseline (variablePromoteIntegration, useGsapPropertyDebounce, sdkCutover(Parity), sdkResolverShadow), zero new regressions. Typecheck, oxlint, oxfmt clean.
… matching, pointercancel revert Fixes real bugs from two independent re-reviews (#2225 @ 65954c3, #2416 @ beaf4ff): - FlatTimingRow's pinRange committed a pinned start+duration range through TWO sequential onSetAttribute calls. Each resolves domEditSelection fresh from current hook state, so a selection change between the two awaits could misdirect the second write at the newly-selected element instead of the one being edited, and a failure of just the second call left the pair half-applied (inconsistent inferred/explicit state). Added commitDataAttributes/handleDomAttributesCommit (mirroring onCommitAnimatedProperties's same-shaped fix for GSAP property batches): one PatchOperation[] persist call against an explicit, caller-supplied selection — not the "current" one — threaded through as the new optional onSetAttributes prop. pinRange uses it when provided, falls back to the old sequential behavior otherwise. - Hide All silently dropped nested sub-composition children: a selection inside a sub-comp with no timeline-store entry of its own resolves to a virtual `sourceFile#domId` key (the fallback branch exists so the expansion hook can later resolve it via clipParentMap), but toggleTimelineElementHidden only searched the RAW store list, which never contains that key. useTimelineElementVisibilityEditing now resolves against useExpandedTimelineElements() instead, matching the track-based toggle's existing approach — the expanded list synthesizes a real, patchable TimelineElement (matching key/domId/sourceFile) for each visible child whenever its host is currently expanded. - Two composition hosts importing the same sub-composition collapsed to the first one: findMatchingTimelineElementId ORed domId/selector/ compositionSrc matches with equal priority in a single per-element scan, so `.find()` could stop at an EARLIER, unrelated host that merely shared the compositionSrc, before the scan ever reached the correct domId/ selector match further down the list. Restructured to try domId, then selector, across the WHOLE list first; compositionSrc-only matching is now a true last resort for when neither identifies a specific element. - FlatSlider's native pointercancel handler (a platform-level gesture abort — scroll/touch takeover, pen leaving range) manually duplicated the pointer-capture release logic instead of calling cancelDrag, so it never reverted to the pre-drag value — leaving whatever intermediate position the pointer last reached committed, unlike the Escape/right-click paths added in the previous round. Now calls cancelDrag directly. - useColorGradingController's flushPendingPersist read identityKeyRef.current fresh at flush time rather than a value snapshotted when the edit was scheduled. Defensive fix: added pendingPersistIdentityRef, set alongside pendingPersistValueRef in commitColorGrading, read by flushPendingPersist instead of the live ref — closes the gap regardless of how unlikely the actual race is given the identity-cleanup effect's existing eager-flush behavior. Two prior findings re-verified as already fixed further up this same Graphite stack (not re-fixed here, per established stack-order handling): metadata-cache negative-caching (267cdfc) and cross-file selectionIdentityKey (6f40e03), both landing after #2225's reviewed head. StudioRightPanel.tsx crossed the 600-line file-size gate after wiring the new onSetAttributes prop through; extracted the inspector split-pane resize handlers (previously inlined) into their own useInspectorSplitResize hook. New regression tests: repeated-composition-host resolution, atomic vs. fallback pinRange commit paths, pointercancel revert. Full studio suite still at the known pre-existing 55-failure baseline, zero new regressions. Typecheck/oxlint/oxfmt clean.
The base branch was changed.
…he flat-inspector stack Fixes issues raised in the Deepwork re-review of #2120-#2190 that weren't covered by #2225's earlier fix pass: - useColorGradingController: reset grading/compare/mediaMetadata state (and cancel pending persist/status timers) when selection changes to a different element — this hook is called unconditionally on every render (unlike legacy ColorGradingSection, remounted via a selectionIdentityKey React key), so switching selection reused the previous element's state. - useColorGradingController: stop permanently caching a non-OK /media/metadata response as null — a transient server error poisoned the HDR banner for that asset for the whole page lifetime. - FlatSelectRow: preserve a valid authored value outside the preset list (e.g. mix-blend-mode: difference, an arbitrary object-position) instead of silently misrepresenting it as the first preset — touching the control would overwrite real persisted state. - FlatSlider: the throttled trailing commit now reads onCommit through a ref updated every render instead of closing over it at schedule time — a caller whose onCommit spreads other current state (Grade's per-detail commits) could otherwise have a delayed commit revert whatever the user changed on a different control in the same 40ms window. - FlatSlider: flush a still-queued trailing commit on unmount instead of dropping it, and disable the reset button when the slider itself is disabled. - FlatSlider: add touch-action: none to the track so touch drags don't compete with page scroll. - FlatColorGradingAccessory: clean up the compare-hold's window listeners on unmount, not only on release — switching selection mid-hold used to leak them. - Align (flat Text): re-clicking the option already visually active for a logical start/end value no longer rewrites it to the physical left/right, preserving RTL semantics. - FlatSegmentedRow: give every option an accessible name and aria-pressed state — two visually-identical glyph buttons (upright/italic "A") had no way to be told apart by assistive tech. - PropertyPanelFlat: the panel body falls back to its own scroll when the collapsed group headers alone exceed the available height, so groups can't become permanently unreachable in a short pane. New regression tests for all of the above; full studio suite at the known pre-existing baseline (55 failures unrelated to this stack).
…review Fixes the Deepwork tip re-review's four remaining blockers plus its additive findings: - selectionIdentityKey: add sourceFile as a 5th identity component. The same local id/selector can legitimately recur across different composition files (host vs. an inlined sub-composition, or two unrelated sub-comps) — without sourceFile, those collided onto the same identity key and reused stale controller state across a selection change that should have reset it. - useColorGradingController: flush (not discard) a pending Grade edit when selection changes before the 350ms debounce fires. The prior fix correctly stopped it from landing on the WRONG (new) target, but cancelling outright silently dropped the user's in-flight edit instead of writing it to the element it was authored for — using the onSetAttributeLive closure captured for the outgoing render, which (via commitDataAttribute's own useCallback deps) is still bound to the outgoing selection. - useColorGradingController: revert to the last confirmed-good grading when a persist rejects, instead of leaving the optimistic (never-actually- saved) value showing indefinitely. Tracks a separate confirmedGradingRef, updated only on a successful persist. - FlatSelectRow: disable the reset button when the row itself is disabled (it previously ignored disabled entirely, same class of bug as the FlatSlider reset button fixed earlier) and give the underlying <select> an aria-label from the row's label text. - FlatSlider: handle lostpointercapture the same as pointercancel — capture can be lost without either firing first (another element steals it, or the browser reclaims it for a scroll/touch gesture), which previously left the dragging flag stuck and the knob permanently unable to sync to external value changes. New regression tests for all of the above; full studio suite still at the known pre-existing baseline (55 failures unrelated to this stack).
…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.
…ect cleanup not render Fixes three of the adversarial findings from the third #2416 tip re-review: - Grade rollback was identity-scoped but not attempt-scoped: two edits on the SAME element (e.g. drag Exposure, then Contrast, before Exposure's persist settles) could have the earlier edit's late completion stamp confirmedGradingRef with its now-superseded value, or revert `grading` out from under the newer optimistic edit. Added a monotonic per-commit version via the existing bumpDomEditCommitVersion primitive (the same one the DOM-attribute commit runner uses for the identical race) — persistColorGradingValue now checks both identity AND "is this still the latest attempt for this element" before applying any effect. - The render-phase identity-reset block consumed shared mutable state (clearing the pending-persist timer, reading and nulling pendingPersistValueRef) directly during render. Adjusting STATE during render this way is React's documented pattern and safe to repeat, but consuming a ref this way is not: if React discarded/interrupted that specific render before it committed, the timer would already be cancelled and the pending value already nulled, with no corresponding effect ever running to compensate, silently losing the edit. Replaced with the idiomatic pattern for "clean up a per-identity resource when it changes" — a useEffect keyed on identityKey whose CLEANUP performs the cancellation/flush. A cleanup only ever runs for the effect instance that actually committed, closing the gap entirely. The render-phase block now only performs pure, idempotent state resets. - FlatSelectRow's Preset row passes label="" (the visible "Preset" text is a sibling span, to avoid rendering it twice) which left the underlying <select> with no accessible name at all. Added a dedicated `ariaLabel` prop, distinct from the visible `label`, so a caller can supply a name without a duplicate visible label. Also hardened FlatSlider's lostpointercapture handling: it now resyncs the draft directly from a latestValueRef immediately, instead of only clearing the dragging flag and waiting for the separate [value]-keyed effect to notice — closing a narrow ordering gap where a value change arriving while still dragging, followed by capture loss with no further render, could otherwise leave the knob stuck. propertyPanelFlatPrimitives.tsx crossed the 600-line file-size gate after these changes; extracted FlatToggle (and its tests) into their own files, matching the FlatMaskInsetRows precedent from an earlier commit in this stack. New/updated regression tests: same-element version race, Preset select's aria-label. Full studio suite still at the known pre-existing 55-failure baseline, zero regressions.
…schedule-time callback Fixes the four blockers from the #2416 re-review at head d6a40c3: - FlatSlider's onPointerUp calls releasePointerCapture() explicitly, which fires lostpointercapture SYNCHRONOUSLY in real browsers — the prior unconditional onLostPointerCapture resync ran mid-onPointerUp, flipping draggingRef false before onPointerUp's own check, silently dropping every normal drag-release's final commitDraft(). happy-dom doesn't replicate the synchronous cascade, so this shipped without a failing test. Added an explicitReleaseRef flag set right before each deliberate releasePointerCapture() call so onLostPointerCapture can tell "our own release, caller's logic already handles it" apart from a genuine external capture loss. Added a regression test that monkey-patches releasePointerCapture to reproduce the real-browser ordering. - persistColorGradingValue read onSetAttributeLiveRef.current (reassigned every render) instead of the callback live when the debounced edit was scheduled — a timer for element A firing after a re-render for element B would wrongly call B's callback with A's data. Removed the ref; the callback is now an explicit parameter captured by commitColorGrading's own closure (added to its useCallback deps) and threaded through to persistColorGradingValue and flushPendingPersist. - flushPendingPersist passed () => true as its isLatestAttempt checker, bypassing the per-commit version guard entirely. Now calls bumpDomEditCommitVersion(gradingVersionRef) like a regular debounced commit, so a newer edit landing before the flushed write settles still wins the race. - The selection-identity cleanup effect stopped clearing statusTimersRef during an earlier refactor — stale RUNTIME_STATUS_REFRESH_DELAYS timers for an outgoing element could fire after switching selection and stamp the new element's runtimeStatus with the old element's answer. Restored the clear in the same effect cleanup. Also gave the Custom LUT and "Copy grade to" scope <select> controls aria-labels — both had their visible text in a sibling span/text node, so neither had an accessible name. Full studio suite still at the known pre-existing 55-failure baseline (variablePromoteIntegration, useGsapPropertyDebounce, sdkCutover(Parity), sdkResolverShadow), zero new regressions. Typecheck, oxlint, oxfmt clean.
A fresh full-stack re-review checked 15 PR heads independently. Cross- checked all 9 remaining claims against the actual current tip: - #2120 (id/selector key qualification, Hide All no-op/race, variable parity), #2121 (opacity-zero fallback), #2122 (GSAP preview sibling resolution, scrub-label), #2124/#2126 (negative metadata cache), and the keyboard-access half of #2121/#2186 were all already fixed by a later commit in this same stack (65954c3, PR #2225) — the reviewed heads predate it. Verified each in the current source rather than taking the isolated-head review at face value. - #2186's "no ESC/right-click cancel during drag" was the one claim that held up: FlatSlider had keyboard arrow-key support but no way to abort an in-progress pointer drag. Escape now reverts to the pre-drag value and releases pointer capture; a right-click (contextmenu) during a drag does the same instead of committing whatever position the pointer last reached while the native context menu opens over the slider. Both go through commitDraft (not just a visual reset) since the drag's leading- edge commit in onPointerDown may already have applied an intermediate value that needs actually undoing, not just hiding. propertyPanelFlatPrimitives.tsx crossed the 600-line file-size gate after this change; extracted FlatSelectRow into its own file, matching the FlatToggle/FlatMaskInsetRows precedent from earlier in this stack. New regression tests for Escape-cancel and contextmenu-cancel. Full studio suite still at the known pre-existing 55-failure baseline, zero new regressions. Typecheck/oxlint/oxfmt clean.
… matching, pointercancel revert Fixes real bugs from two independent re-reviews (#2225 @ 65954c3, #2416 @ beaf4ff): - FlatTimingRow's pinRange committed a pinned start+duration range through TWO sequential onSetAttribute calls. Each resolves domEditSelection fresh from current hook state, so a selection change between the two awaits could misdirect the second write at the newly-selected element instead of the one being edited, and a failure of just the second call left the pair half-applied (inconsistent inferred/explicit state). Added commitDataAttributes/handleDomAttributesCommit (mirroring onCommitAnimatedProperties's same-shaped fix for GSAP property batches): one PatchOperation[] persist call against an explicit, caller-supplied selection — not the "current" one — threaded through as the new optional onSetAttributes prop. pinRange uses it when provided, falls back to the old sequential behavior otherwise. - Hide All silently dropped nested sub-composition children: a selection inside a sub-comp with no timeline-store entry of its own resolves to a virtual `sourceFile#domId` key (the fallback branch exists so the expansion hook can later resolve it via clipParentMap), but toggleTimelineElementHidden only searched the RAW store list, which never contains that key. useTimelineElementVisibilityEditing now resolves against useExpandedTimelineElements() instead, matching the track-based toggle's existing approach — the expanded list synthesizes a real, patchable TimelineElement (matching key/domId/sourceFile) for each visible child whenever its host is currently expanded. - Two composition hosts importing the same sub-composition collapsed to the first one: findMatchingTimelineElementId ORed domId/selector/ compositionSrc matches with equal priority in a single per-element scan, so `.find()` could stop at an EARLIER, unrelated host that merely shared the compositionSrc, before the scan ever reached the correct domId/ selector match further down the list. Restructured to try domId, then selector, across the WHOLE list first; compositionSrc-only matching is now a true last resort for when neither identifies a specific element. - FlatSlider's native pointercancel handler (a platform-level gesture abort — scroll/touch takeover, pen leaving range) manually duplicated the pointer-capture release logic instead of calling cancelDrag, so it never reverted to the pre-drag value — leaving whatever intermediate position the pointer last reached committed, unlike the Escape/right-click paths added in the previous round. Now calls cancelDrag directly. - useColorGradingController's flushPendingPersist read identityKeyRef.current fresh at flush time rather than a value snapshotted when the edit was scheduled. Defensive fix: added pendingPersistIdentityRef, set alongside pendingPersistValueRef in commitColorGrading, read by flushPendingPersist instead of the live ref — closes the gap regardless of how unlikely the actual race is given the identity-cleanup effect's existing eager-flush behavior. Two prior findings re-verified as already fixed further up this same Graphite stack (not re-fixed here, per established stack-order handling): metadata-cache negative-caching (267cdfc) and cross-file selectionIdentityKey (6f40e03), both landing after #2225's reviewed head. StudioRightPanel.tsx crossed the 600-line file-size gate after wiring the new onSetAttributes prop through; extracted the inspector split-pane resize handlers (previously inlined) into their own useInspectorSplitResize hook. New regression tests: repeated-composition-host resolution, atomic vs. fallback pinRange commit paths, pointercancel revert. Full studio suite still at the known pre-existing 55-failure baseline, zero new regressions. Typecheck/oxlint/oxfmt clean.
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.
fe4d135 to
07cfc4c
Compare

What
Brief description of the change.
Why
Why is this change needed?
How
How was this implemented? Any notable design decisions?
Test plan
How was this tested?