Skip to content

improvement(emcn): let a modal refuse every dismissal while an action runs - #6276

Merged
waleedlatif1 merged 3 commits into
stagingfrom
improvement/modal-dismiss-guard
Aug 5, 2026
Merged

improvement(emcn): let a modal refuse every dismissal while an action runs#6276
waleedlatif1 merged 3 commits into
stagingfrom
improvement/modal-dismiss-guard

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • ChipModal / ModalContent take dismissDisabled, which holds all four exits shut while an action is in flight: Escape, outside-click, the header close button, and the footer Cancel
  • ChipConfirmModal now passes dismissDisabled={confirm.pending}, making its TSDoc true — it promised "a single dismiss path shared by the header X / dismiss button / Escape … and disabling dismiss while the confirm is in flight", but only the dismiss button was ever guarded
  • Fixes two live bugs: edit-connector-modal and add-connector-modal guarded onOpenChange against a pending save, then handed the header X a direct onOpenChange(false) that skipped the guard — you could close them mid-save via the X
  • Turns on turbo run type-check for every workspace (was --filter=@sim/realtime only)

Why the guard lives in ModalContent

{...props} is spread after that component's own handlers, so a consumer-passed onEscapeKeyDown / onInteractOutside would silently drop the floating-layer guard whose comment explains it prevents a frozen-page bug. The flag is published through a context that ChipModalHeader, ChipModalFooter and ModalHeader read, so ModalHeader's built-in DialogPrimitive.Close is covered too — otherwise the five non-ChipModal consumers would get a modal that traps Escape and outside-click but still closes on X.

closeDisabled / cancelDisabled compose with ||, matching ButtonGroupItem's precedent: an explicit true still disables one button, an explicit false cannot re-enable a button whose click Radix has already been told to ignore. All 60 existing ChipModalHeader sites and 28 cancelDisabled consumers are unaffected.

Type-check gate

All 23 workspaces pass today, so it lands green. packages/emcn, packages/utils, apps/desktop and apps/docs had no type check in CI; apps/sim's source was covered only as a side effect of next build. Added inputs to the turbo task so non-TS changes stop busting the cache.

This does not cover apps/sim's tests — its tsconfig excludes *.test.ts(x), and including them surfaces ~2.2k errors. That's its own cleanup, not a gate to switch on here.

Testing

packages/emcn/src/components/chip-modal/chip-modal.test.tsx covers the buttons, Escape, both composition directions, and ChipConfirmModal's pending state. Each assertion verified to fail with the guard reverted.

Checked all 62 ChipConfirmModal usages for a pending flag that could stick — the change removes Escape as an escape hatch, so a flag that never resets would trap the user. Every manual useState flag resets in a finally; the rest use React Query's .isPending. None can strand.

Not covered: outside-click is guarded by the same flag but is not asserted — jsdom cannot drive Radix's outside-interaction path, so the assertion could never fail. It's code-reviewed only. Nothing here has been opened in a browser.

Follow-ups (not in this PR)

  • 29 modals still pass cancelDisabled={pending} alone, leaving Escape and outside-click open. Each needs its pending source verified, so it's a separate reviewed change rather than a blind sweep
  • ModalContent.showClose is destructured and never read — ChipModal passes showClose={false} believing it does something. Pre-existing no-op
  • deploy-modal's chatSubmitting is the most fragile consumer: three early returns inside its try, correct only because finally resets it

Type of Change

  • Bug fix
  • Improvement

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

… runs

ChipConfirmModal's docs promised "a single dismiss path shared by the header X /
dismiss button / Escape … and disabling dismiss while the confirm is in flight".
Only the dismiss button was ever guarded — Escape, outside-click and the header X
all still closed a confirmation mid-delete. Two knowledge-base connector modals
had the same shape: they guarded onOpenChange against a pending save, then handed
the header X a direct onOpenChange(false) that skipped the guard.

A modal now states the interlock once, as `dismissDisabled` on ChipModal or
ModalContent, and the primitive holds all four exits shut. ModalContent owns the
Radix paths because `{...props}` is spread after its own handlers, so a
consumer-passed onEscapeKeyDown/onInteractOutside would silently drop the
floating-layer guard; it publishes the flag through a context that
ChipModalHeader, ChipModalFooter and ModalHeader read. The two narrow props
compose with `||`, so an explicit `true` still disables a single button and an
explicit `false` cannot punch a hole in the root's guarantee.

Also turns on `turbo run type-check` for every workspace. packages/emcn,
packages/utils, apps/desktop and apps/docs had no type check in CI at all —
only @sim/realtime did — and apps/sim's source was covered solely as a side
effect of `next build`. All 23 workspaces pass today, so it lands green.
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview Aug 5, 2026 2:32am

Request Review

@cursor

cursor Bot commented Aug 5, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches shared modal dismissal and Radix event ordering used app-wide; CI now gates more packages on type-check, though the PR notes all workspaces pass today.

Overview
Adds dismissDisabled on ModalContent / ChipModal so a single flag blocks Escape, outside-click, header close, and Cancel while work is in flight, wired through useModalDismissDisabled and Radix preventDefault on escape/outside. ChipConfirmModal now sets dismissDisabled={confirm.pending} so pending confirms match their documented behavior.

Fixes add/edit connector modals that only guarded onOpenChange for save/connect — the header X could still close mid-mutation; they now use dismissDisabled={isCreating|isSaving} instead of per-button cancelDisabled.

CI runs bunx turbo run type-check for all workspaces (was @sim/realtime only); turbo.json adds type-check inputs so unrelated changes don’t invalidate the cache. New chip-modal.test.tsx covers dismiss paths and confirm pending state.

Reviewed by Cursor Bugbot for commit 03f6449. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds a shared modal dismissal interlock and applies it to confirmation and connector modals while expanding CI type-checking.

  • Composes consumer Escape and outside-interaction handlers without replacing internal dismissal guards.
  • Disables header and footer dismissal controls through modal context while actions are pending.
  • Adds dismissal-path tests and runs type-checking across all workspaces.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the current handler composition fixes the previously reported dismissal-guard replacement path.

Important Files Changed

Filename Overview
packages/emcn/src/components/modal/modal.tsx Adds dismissal context and composes consumer handlers after authoritative Escape and outside-interaction guards, fully addressing the prior handler-replacement issue.
packages/emcn/src/components/chip-modal/chip-modal.tsx Propagates the modal-wide interlock to ChipModal close and Cancel controls and enables it while confirmations are pending.
packages/emcn/src/components/chip-modal/chip-modal.test.tsx Covers Escape and button dismissal behavior, handler composition, and pending confirmation state.
apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx Replaces path-specific pending guards with the shared modal-wide dismissal interlock.
apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx Uses the shared interlock to prevent every dismissal path during saves.
.github/workflows/test-build.yml Expands the existing CI type-check step from the realtime workspace to all workspaces.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Action becomes pending] --> B[dismissDisabled true]
  B --> C[Escape prevented]
  B --> D[Outside interaction prevented]
  B --> E[Header close disabled]
  B --> F[Footer Cancel disabled]
  G[Consumer dismissal handler] --> H[Runs after internal guard]
  H --> I[Guard remains authoritative]
  J[Action completes] --> K[dismissDisabled false]
  K --> L[Dismissal paths restored]
Loading

Reviews (3): Last reviewed commit: "revert(ci): drop the type-check inputs a..." | Re-trigger Greptile

Comment thread packages/emcn/src/components/modal/modal.tsx
… guard

ModalContent's own onEscapeKeyDown/onInteractOutside sit before the `{...props}`
spread, so a consumer passing either replaced them — dropping both the
dismissDisabled interlock and the floating-layer guard that keeps a popper
dismissal from closing the modal and freezing the page. The TSDoc argued the
guard had to live here for exactly that reason, then left the same spread able
to defeat it.

Both handlers are now destructured out of props and invoked after the guard, so
the guard always runs and a consumer can still observe or extend the event. No
consumer passes either today, so this was latent rather than live.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot 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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 03f6449. Configure here.

The allowlist traded correctness for a modest cache win, in a gate whose only
job is catching type errors. `resolveJsonModule` and `allowJs` are both on, so
.json and .js files participate in type checking and were absent from the list —
`lib/integrations/availability.ts` imports the generated `integrations.json`,
which means regenerating that file would not have invalidated the cache and CI
would have replayed a stale pass over a changed type.

Back to Turbo's default (every non-gitignored file in the package): conservative,
but a type-check gate that can serve a stale success is worse than a slow one.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot 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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 993e8fa. Configure here.

@waleedlatif1
waleedlatif1 merged commit 2c120c3 into staging Aug 5, 2026
30 checks passed
@waleedlatif1
waleedlatif1 deleted the improvement/modal-dismiss-guard branch August 5, 2026 02:40
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.

1 participant