Skip to content

fix(visual-builder): deselect the field when the parent refuses its field lock - #666

Merged
faraazb-contentstack merged 2 commits into
ce-auto-draft-lpfrom
fix/deselect-on-refused-field-lock
Oct 9, 2026
Merged

faraazb-contentstack merged 2 commits into
ce-auto-draft-lpfrom
fix/deselect-on-refused-field-lock

Conversation

@faraazb-contentstack

@faraazb-contentstack faraazb-contentstack commented Oct 8, 2026 •

Copy link
Copy Markdown

Summary

When a toolbar action on the canvas asks the parent to open a picker (Replace on a file or reference field, or the add-instance buttons), the parent now checks the field lock first. If a collaborator holds the field, it does not open the picker and replies { fieldLockRefused: true }. This PR handles that reply in the SDK, so the field is deselected and its toolbar removed instead of staying up on a field the user can't edit.

This only matters in the window where the canvas doesn't know about the other user's lock yet. Once the lock reaches the SDK, the existing locked-field handling already blocks the click and disables the toolbar actions.

Changes

  • FieldToolbar: Replace on file and reference fields now awaits the parent's reply. On fieldLockRefused it calls hideOverlay, but only if the selected element's data-cslp still matches the field that asked. A field the user selected during the round trip keeps its selection.
  • AddInstanceButton and multipleElementAddButton: on a refused reply, the button calls a new onFieldLockRefused callback instead of onClick. That deselects the field (with the same data-cslp check) and skips waiting for a new instance.
  • EmptyBlock: on a refused reply it skips observeParentAndFocusNewInstance, since no instance is coming.
  • OPEN_REFERENCE_MODAL now sends the field's variant. The parent derives the lock path from the variant entry.
  • New utils/fieldLockRefused.ts with isFieldLockRefused and isFieldStillSelected.

A parent that doesn't send the new reply keeps the old behaviour: the reply isn't fieldLockRefused, so nothing is deselected.

Test plan

  • Unit tests for the refused, refused-after-the-user-moved-on and opened cases in fieldToolbar.test.tsx, addInstanceButton.test.tsx, emptyBlock.test.tsx and multipleElementAddButton.test.ts, plus fieldLockRefused.test.ts.
  • npx vitest run: 116 files, 962 tests pass.
  • tsc --noEmit is clean. npm run build shows a DTS error on emptyBlock.tsx (JSX.TargetedMouseEvent) that is already on the base branch; the JS bundle builds.
  • Tested in the browser with two users, using a build of this branch in a sample site against the matching parent change. The refused cases came from real server lock conflicts; the gap before the lock update arrives was held open by blocking one user's realtime connection.

🤖 Generated with Claude Code

…ield lock

Replace on a file or reference field and the add-instance buttons opened a
picker in the parent even when a collaborator held the field, leaving the
field selected with its toolbar up. The parent now answers OPEN_ASSET_MODAL,
OPEN_REFERENCE_MODAL and ADD_INSTANCE with { fieldLockRefused: true } when it
cannot take the lock. On that reply the toolbar deselects the field, but only
if it is still the selected one (matched by data-cslp), so a field the user
picked during the round trip keeps its selection. The empty-block add skips
focusing a new instance, since none is coming.

OPEN_REFERENCE_MODAL now carries the field's variant, which the parent needs
to derive the lock path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@faraazb-contentstack
faraazb-contentstack requested a review from a team as a code owner October 8, 2026 11:55
@snyk-io

snyk-io Bot commented Oct 8, 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.

@hitesh-shetty-cstk hitesh-shetty-cstk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Automated review

Critical journey at risk: inline editing on the canvas. This changes what clears field focus, the edit overlay and the field toolbar, plus the add-instance buttons on modular and multiple fields. If the new deselect fires when it should not, an editor loses their selection and toolbar in the middle of editing a field they do own. If it fails to fire, the toolbar stays on a field a collaborator has locked and every action on it silently does nothing.

What this changes: Replace on file and reference fields, and the add-instance buttons, now await the parent's reply instead of sending and forgetting. When the parent answers { fieldLockRefused: true } because a collaborator holds the field lock, the SDK deselects the field and drops the toolbar rather than leaving it on a field the user cannot edit. A new isFieldStillSelected check keeps a field the user selected during the round trip from being deselected. OPEN_REFERENCE_MODAL also started sending the field's variant.

Business impact: as above. The window this covers is narrow, the gap before the collaborator's lock reaches the canvas, but the code path it touches runs on every Replace click and every add-instance click.

Security: nothing beyond what the scanners cover. The refusal check reads a reply to the SDK's own request and narrows it with a typed === true test, so a malformed or absent reply reads as "not refused" rather than throwing. The authorization decision itself stays on the parent and the SDK only reacts to it, which is the right split. Snyk passed on this head commit across open source, licence and code security.

Flow

sequenceDiagram
    participant User
    participant Button as Replace / add-instance button (SDK)
    participant State as VisualBuilderGlobalState
    participant Parent as Visual Builder parent window

    User->>Button: click
    Button->>Parent: OPEN_ASSET_MODAL / OPEN_REFERENCE_MODAL / ADD_INSTANCE
    Note over Parent: field lock checked before the picker opens
    Parent-->>Button: { fieldLockRefused: true }
    Note over Button: changed here, the reply is now awaited
    Button->>State: is data-cslp still the clicked field?
    State-->>Button: yes
    Button->>Button: hideOverlay, drop the selection and the toolbar
Loading

Findings: 0 blocker, 3 should fix, 2 nit. All five are inline. Three of them are test isolation, one is a type contract, one is a question about the empty-block path.

What I checked and found sound, since this sits on a critical path:

  • hideOverlay nulls previousSelectedEditableDOM, so the guard and the thing it guards agree on what "selected" means.
  • onFieldLockRefused has one non-test caller, multipleElementAddButton, and both the previous and next buttons get it, so the optional prop is fully wired today.
  • In production previousSelectedEditableDOM is the element the toolbar's eventDetails was built from, so comparing its data-cslp against fieldMetadata.cslpValue matches, rather than only matching because the tests set both from one value.
  • fieldLockRefused.ts importing VisualBuilder from ".." closes an import cycle, but it dereferences only inside the function bodies, and four other files under utils/ already do the same.
  • Backward compatibility holds for the reason the description gives. The current parent handler returns undefined on its early return, which resolves to undefined, so isFieldLockRefused is false and nothing is deselected.
  • Awaiting these replies converts a previously unhandled promise rejection into a logged one.

Reviewer candidates:

  • @SahilCs15 authored 10 of the last 30 commits on src/visualBuilder/components/FieldToolbar.tsx and 14 across the five most-changed files, the most of anyone. They are also the top author of the critical-path file here, so please walk them through the deselect conditions before merging.
  • @srinad007 authored 5 of the last 30 commits on FieldToolbar.tsx, second on that file.

Not covered: No tests, type check or build were run, and no app was started. This checkout has no installed dependencies, so the suite result, the clean tsc --noEmit and the pre-existing DTS error in the description are unverified here. The matching parent change is not in the checkout I read, so the refusal contract and the new variant consumer are verified from the SDK side only. I could not check what the post-message layer does when the parent registers no responder at all, as opposed to a responder that returns nothing. E2E behaviour with two live users is unverified.

One question for the summary rather than a line: the parent refuses Replace and add-instance. If it also refuses the toolbar's Edit action, OPEN_FIELD_EDIT_MODAL still sends and forgets, so that path would keep the old behaviour of leaving the toolbar up. Worth confirming the parent's refusal set matches what this PR handles.

Automated review by Claude Code. A human review is still required.


Generated by Claude Code

vi.mocked(FieldSchemaMap.getFieldSchema).mockImplementation(() =>
Promise.resolve(schema)
);
vi.mocked(visualBuilderPostMessage!.send).mockImplementation(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[should fix] This mockImplementation outlives the describe block. The outer afterEach calls vi.clearAllMocks(), which clears recorded calls but leaves the implementation in place, and nothing else restores send. From here on every test in the file gets {} for every event, which replaces the two cases the file's vi.mock factory deliberately special-cases, field-location-data and get-field-variant-status.

The six tests after this block ('Replace button' visibility, the disabled-state test and the restricted-entry tests) therefore run against a send they were not written for. They pass today only because none of them asserts on that data, so this couples them to the order this block runs in. The comment at line 636 in this file, "Restore will happen in outer afterEach via clearAllMocks", relies on the same incorrect assumption.

Restoring in this block's existing afterEach is enough. On the pinned vitest@^4.1.5, mockReset() puts back the implementation that was passed to vi.fn(), which is the factory behaviour:

afterEach(() => {
    VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM =
        null;
    vi.mocked(visualBuilderPostMessage!.send).mockReset();
});

Generated by Claude Code

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in bc2750d. This describe's afterEach now calls mockReset() on send, which puts back the factory implementation. I also corrected the misleading comment on the visibility describe's afterEach: getFieldSchema is reset by the outer beforeEach, not by clearAllMocks.

});
await Promise.resolve();
expect(observeParentAndFocusNewInstance).not.toHaveBeenCalled();
(visualBuilderPostMessage as any).send.mockReset();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[should fix] The cleanup is the last statement of the test body, so it is skipped whenever the assertion above it fails. mockResolvedValue is persistent and the beforeEach here only calls vi.clearAllMocks(), which does not clear a queued resolved value, so a failure in this test leaves { fieldLockRefused: true } answering send for the three tests that follow it. A single real regression then reports as four failures and the later ones point away from the cause.

Moving it into an afterEach makes it run on both paths:

afterEach(() => {
    (visualBuilderPostMessage as any).send.mockReset();
    host?.remove();
    host = null;
});

The other three refusal tests in this PR use mockResolvedValueOnce, which avoids the problem. That works here too, since this test renders with no data-cslp ancestor so the edit stack is empty and ADD_INSTANCE is the only message sent. mockResolvedValue plus an afterEach is the less fragile of the two, because it does not depend on that.


Generated by Claude Code

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in bc2750d. The send reset now runs in afterEach, so it also happens when an assertion fails.

}
);
// A collaborator holds the field: no instance is coming to focus.
if (isFieldLockRefused(response)) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[should fix, question] I am not sure this is a defect, so treat it as a question rather than a finding.

Unlike the other two paths, this one claims the lock before it asks. Lines 45 to 50 send FOCUS_FIELD fire and forget, then ADD_INSTANCE goes out immediately after. On a refusal this returns without telling the parent anything, so if the FOCUS_FIELD claim can land while the refusal is in flight, the parent is left holding this user as the focused editor on a field they did not get, and nothing in the SDK will clear it because the empty-block path never selected the field locally.

If a refusal always implies the claim was rejected too, because the collaborator already held the lock, then there is nothing to release and this is correct as written. Can you confirm that is the parent's behaviour? If the claim can succeed independently, this needs a deselect on the refusal, and the comment on line 44 suggests sending an empty edit stack is how the parent reads one.

Either way, a one-line comment here saying why no release is needed would save the next reader the same trip through the parent.


Generated by Claude Code

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Confirmed, nothing to release. On the parent, FOCUS_FIELD and the ADD_INSTANCE lock check both act on the same focus claim, for the same lock path. A refusal is the answer to that claim: on a conflict the controller drops the field, and on a timeout it releases it explicitly. So the parent holds nothing for this field when the reply arrives. The SDK side never selected the field, so there's nothing to deselect either. I added a comment saying this in bc2750d.


test("sends the variant with the reference replace request", async () => {
selectElementWithCslp(instanceMetadata.cslpValue);
instanceMetadata.variant = "variant_1";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[nit] instanceMetadata is shared with the test.each cases above, and this mutation is undone on the last line of the test. If the assertion fails, the reset is skipped and variant stays "variant_1" for anything that runs after it.

A local copy keeps the mutation out of the shared object and drops the need to reset at all:

await clickReplace("reference", referenceFieldSchema, undefined, {
    ...instanceMetadata,
    variant: "variant_1",
});

Passing the metadata through clickReplace means a small signature change. Setting the value in a beforeEach and clearing it in an afterEach would also work if you would rather not touch the helper.


Generated by Claude Code

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in bc2750d. clickReplace now takes the metadata as an optional argument, and the variant test passes its own copy, so the shared object isn't touched.

value: any;
onClick: (event: MouseEvent) => void;
/** Called instead of `onClick` when a collaborator holds the field lock. */
onFieldLockRefused?: () => void;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[nit] Worth considering making this required. There is one non-test caller, handleAddButtonsForMultiple, and it passes the callback for both buttons, so nothing is relying on the prop being optional.

As it stands, a future caller that omits it gets a button that does nothing at all on a refusal: props.onFieldLockRefused?.() is a no-op and the return below skips props.onClick(event) as well. That is the same class of dead control as the bug this PR fixes, and it would fail silently rather than at the type level. Making it required here and in the generateAddInstanceButton parameter type turns it into a compile error instead.


Generated by Claude Code

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in bc2750d. onFieldLockRefused is now required on AddInstanceButton and on generateAddInstanceButton. The test call sites pass it too.

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 70.35% 2810 / 3994
🔵 Statements 69.26% 2862 / 4132
🔵 Functions 68.22% 511 / 749
🔵 Branches 64.97% 1736 / 2672
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/visualBuilder/components/FieldToolbar.tsx 77.94% 77.08% 68% 77.94% 90-93, 143-157, 277-293, 315-317, 382-388, 398, 402, 455-459, 478
src/visualBuilder/components/addInstanceButton.tsx 95.23% 75% 100% 95.23% 44
src/visualBuilder/components/emptyBlock.tsx 87.5% 100% 100% 85.71% 65-66
src/visualBuilder/generators/generateAddInstanceButtons.tsx 100% 100% 100% 100%
src/visualBuilder/utils/fieldLockRefused.ts 100% 100% 100% 100%
src/visualBuilder/utils/multipleElementAddButton.ts 82.71% 76.08% 70% 83.54% 53, 84, 219, 258-281
Generated in workflow #943 for commit bc2750d by the Vitest Coverage Report Action

…eldLockRefused

Reset the `send` mock after the toolbar refusal tests and after each empty
block test, so a test's stubbed reply cannot answer `send` in later tests.
Pass the variant test its own metadata copy instead of mutating the shared
one. Make onFieldLockRefused required: a button without it would do nothing
on a refusal. Note why the empty-block add has no claim to release when its
request is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@faraazb-contentstack
faraazb-contentstack requested a review from a team as a code owner October 9, 2026 11:56
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

🔒 Security Scan Results

ℹ️ Note: Only vulnerabilities with available fixes (upgrades or patches) are counted toward thresholds.

Check Type Count (with fixes) Without fixes Threshold Result
🔴 Critical Severity 0 0 10 ✅ Passed
🟠 High Severity 0 0 25 ✅ Passed
🟡 Medium Severity 2 0 500 ✅ Passed
🔵 Low Severity 0 0 1000 ✅ Passed

⏱️ SLA Breach Summary

✅ No SLA breaches detected. All vulnerabilities are within acceptable time thresholds.

Severity Breaches (with fixes) Breaches (no fixes) SLA Threshold (with/no fixes) Status
🔴 Critical 0 0 15 / 30 days ✅ Passed
🟠 High 0 0 30 / 120 days ✅ Passed
🟡 Medium 0 0 90 / 365 days ✅ Passed
🔵 Low 0 0 180 / 365 days ✅ Passed

✅ BUILD PASSED - All security checks passed

@faraazb-contentstack
faraazb-contentstack merged commit 269bd3a into ce-auto-draft-lp Oct 9, 2026
9 checks passed
@faraazb-contentstack
faraazb-contentstack deleted the fix/deselect-on-refused-field-lock branch October 9, 2026 11:57
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