Skip to content

fix(visual-builder): claim the field lock from the empty-block add - #641

Merged
SahilCs15 merged 2 commits into
ce-auto-draft-lpfrom
DRFT-209
Sep 11, 2026
Merged

SahilCs15 merged 2 commits into
ce-auto-draft-lpfrom
DRFT-209

Conversation

@SahilCs15

Copy link
Copy Markdown
Contributor

What

The empty-state placeholder for a multiple field now claims the auto-draft field
lock before it adds the first instance.

Why

The placeholder (VB_EmptyBlockParentClass) never selects the field. The canvas
click listener is capture-phase and returns early for empty blocks, so nothing
sent FOCUS_FIELD and no lock was claimed. Another editor was never told the
field was being changed, and the add went through even when a peer held it.

How

  • Send FOCUS_FIELD with the element's edit stack before ADD_INSTANCE, matching
    the click listener. Fire and forget, because the parent's lockFocusedField is
    synchronous and does not await the claim either, so awaiting the message would
    only add the parent's content-type and entry fetches to the click path.
  • Skip the send when the edit stack is empty. The parent reads an empty stack as a
    deselect and calls releaseCurrentFieldLock(), which would release the lock
    instead of claiming it.
  • Refuse the add when getPeerLockForField reports a peer lock, the same no-op a
    click on a peer-locked field already gets.
  • Wrap the ADD_INSTANCE send in try/catch, as addInstanceButton does.

Testing

  • 3 new unit cases: the lock is claimed before the add, an empty edit stack is not
    sent, and a peer-held field adds nothing. Each was red before the change.
  • Full suite: 910 tests across 114 files passing. Prettier and eslint clean.
  • Verified on hosted dev11 in the standalone canvas with a locally packed build:
    clicking the placeholder add sends POST /draft/focus (201) and the lock appears
    in _field_lock_info with a TTL. Repeated on a modular-blocks field and a
    multiple file field nested in a block. On the modular-blocks field the lock then
    narrows to the new instance path and the instance is logged in the change set.

Not in this change

Still open on the ticket, deliberately out of scope here: the instance delete that
truncates a multiple field (reproduces through both the canvas and the draft API,
and looks server-side in the draft apply step), the parent-to-child lock hand-off
that leaves a brief window with no lock held, and reorder.

The empty-state placeholder never selects the field, so its add button
claimed no lock and a peer editor was never told the field was changing.
Send FOCUS_FIELD with the element's edit stack before ADD_INSTANCE, skip
the send when the stack is empty (the parent reads that as a deselect and
would release the lock), and refuse the add outright when a peer holds the
field, matching the click listener's peer-lock gate.
@SahilCs15
SahilCs15 requested a review from a team as a code owner September 9, 2026 12:20
@snyk-io

snyk-io Bot commented Sep 9, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 68% 2636 / 3876
🔵 Statements 66.89% 2679 / 4005
🔵 Functions 65.41% 469 / 717
🔵 Branches 62.79% 1627 / 2591
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/visualBuilder/components/emptyBlock.tsx 85.71% 100% 100% 84.61% 60-61
Generated in workflow #906 for commit 07a1c65 by the Vitest Coverage Report Action

@faraazb-contentstack faraazb-contentstack left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some comments

Comment on lines +49 to 52
} catch (error) {
console.error("Visual Builder: Failed to add instance", error);
}
observeParentAndFocusNewInstance({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catch swallows failure but control falls through to observeParentAndFocusNewInstance. Before this change a rejected ADD_INSTANCE propagated and skipped the observe; now a failed add still starts an observer waiting for an instance that will never appear (and the mutation observer / focus attempt lingers).

Either return from the catch, or move the observe into the try after the await.

Suggested change
} catch (error) {
console.error("Visual Builder: Failed to add instance", error);
}
observeParentAndFocusNewInstance({
} catch (error) {
console.error("Visual Builder: Failed to add instance", error);
return;
}
observeParentAndFocusNewInstance({

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in 07a1c65. The catch returns now, so a failed add no longer leaves an observer waiting on an instance that never arrives.

I went with the return rather than moving the observe inside the try, so the success path still reads top to bottom.


// The empty-state add never selects the field, so nothing else claims the
// lock. Fire and forget: the parent does not await the claim either.
const DOMEditStack = getDOMEditStack(event.currentTarget as Element);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

event.currentTarget is the add button, so the stack is derived from the button's ancestors. The capture-phase click listener builds the stack from the clicked field element. Are these guaranteed identical here — i.e. is the placeholder always rendered inside the data-cslp field element, not in a portal/overlay layer? If the placeholder ever renders outside the field subtree, the stack silently comes back empty and the add proceeds with no lock (the DOMEditStack.length branch is skipped, not blocked).

Would be more robust to resolve the element from details.fieldMetadata.cslpValue rather than DOM position.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair challenge. Today they are identical: generateEmptyBlocks reads data-cslp off emptyBlockParent and then hydrates the component into that same element, so the button is always inside the field subtree. So this is not currently reachable.

Switched to your suggestion anyway in 07a1c65, since it is also what the other two FOCUS_FIELD senders do. Both mouseClick.ts:451 and handleFormFieldFocus in FieldToolbar.tsx pass the resolved editableElement, not a button, so resolving by cslp makes this consistent with them rather than being the odd one out:

const fieldElement =
    document.querySelector(
        `[${DATA_CSLP_ATTR_SELECTOR}="${details.fieldMetadata.cslpValue}"]`
    ) ?? event.currentTarget;

Kept currentTarget as the fallback so a missed lookup degrades to the old behaviour instead of an empty stack.

Comment on lines +91 to +93
onClick={(e) =>
sendAddInstanceEvent(e as unknown as MouseEvent)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double cast through unknown is a smell. Preact gives JSX.TargetedMouseEvent<HTMLButtonElement>; typing the handler param as that drops both casts here and the as Element on currentTarget.

async function sendAddInstanceEvent(
    event: JSX.TargetedMouseEvent<HTMLButtonElement>
) { ... }

then onClick={sendAddInstanceEvent}.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 07a1c65. Typing the param as JSX.TargetedMouseEvent<HTMLButtonElement> dropped both casts and let the handler be passed straight to onClick. The as Element on currentTarget went with it.

Comment thread src/visualBuilder/components/__test__/emptyBlock.test.tsx
Comment thread src/visualBuilder/components/__test__/emptyBlock.test.tsx
Comment thread src/visualBuilder/components/__test__/emptyBlock.test.tsx Outdated
- Return from the ADD_INSTANCE catch so a failed add no longer starts an
  observer waiting for an instance that will never appear.
- Resolve the field element from the cslp rather than the button's DOM
  position, so a portal render cannot silently yield an empty edit stack
  and skip the lock claim. Falls back to the button.
- Type the handler as JSX.TargetedMouseEvent<HTMLButtonElement>, dropping
  the double cast through unknown and the cast on currentTarget.
- Clear mocks in beforeEach rather than afterEach, remove the appended
  host node RTL does not clean up, and replace the fixed 10-microtask
  drain with a waitFor on an observable signal.

@faraazb-contentstack faraazb-contentstack left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!

@SahilCs15
SahilCs15 merged commit 3545170 into ce-auto-draft-lp Sep 11, 2026
9 of 10 checks passed
@SahilCs15
SahilCs15 deleted the DRFT-209 branch September 11, 2026 06:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants