open-design/apps/web/tests
estelledc 1a6face04c
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 0s
nix-check / build (push) Failing after 1s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 1s
ci / Workspace unit tests (push) Failing after 1s
ci / Daemon workspace tests (push) Failing after 1s
ci / Web workspace tests (push) Failing after 1s
ci / Browser tests (push) Failing after 1s
ci / Build workspaces (push) Failing after 1s
ci / Validate workspace (push) Failing after 0s
ci / Runtime trace (push) Has been skipped
fix(web): prune draft tokens when the plugin chip strip clears (#2881) (#3356)
ChatComposer tracks the `@…` tokens this surface authored via the
@-mention popover plugin-pick path. When PluginsSection's chip strip
clears, we wire its `onCleared` and prune *only* those tracked
insertions from the draft so the textarea no longer holds orphaned
styled mentions whose chips just unmounted.

Architecture summary (rounds 1–9 collapsed; round 10 detailed below):

  - `Array<{token, start, pluginId, insertionId?}>` tracking with
    start offsets reconciled across each keystroke via an LCS+LCP
    edit-range diff in
    `apps/web/src/utils/pluginInsertionTracking.ts` (round 3-4).
    `insertionId` is forwarded by `reconcileInsertions` so the
    producer can locate its own entry across reconciles
    (round 10).
  - All draft mutations route through a single `updateDraft`
    chokepoint that runs `reconcileInsertions` outside the
    `setDraft` updater so React StrictMode's double-invoke is
    harmless (round 4-5).
  - Boundaries delegate to the shared
    `inlineMentions.isMentionBoundary` /
    `inlineMentions.isMentionRightBoundary` helpers so the
    tracker can never diverge from the parser (round 5).
  - `setActivePlugin` is a chokepoint for every applyById path,
    filtering tracked entries to those matching the new active
    plugin so a replace-plugin flow can never let stale entries
    survive (round 6).
  - Picker rollback double-snapshots draft + tracker so apply-
    failure restores the tracker but only rewrites the draft
    when no user keystrokes arrived during the await
    (round 7-8).
  - `stripPluginInsertedTokens` collapses whitespace seam-local
    so user-authored multi-space spans elsewhere are preserved
    (round 8).
  - `setActivePlugin` is deferred past `await applyById` on
    every path, and `onCleared` filters by
    `pluginsSectionRef.current?.getActiveRecord()?.id` so a
    pending-window clear scopes to the actually-mounted
    plugin's tokens (round 9).

race in the picker rollback:

  Round 9 made `onCleared` mutate the tracker and the draft when
  it ran during a pending replace, and added the `getActiveRecord`
  filter so the strip targets the still-mounted plugin's entries
  only. The picker's failure-path rollback, however, still
  restored `prevEntries` / `prevActiveId` wholesale — assuming
  nothing else had touched the tracker during the await. If the
  user clicked the still-mounted original chip's × during the
  pending replace AND the deferred `applyById` then resolved
  with a 500, the wholesale restore (a) resurrected entries that
  `onCleared` had legitimately stripped (now stale offsets) and
  (b) left the optimistic `@<target>` orphaned in the draft with
  no chip ever having mounted — the original #2881 symptom
  recurring inside the failure window.

  Fix splits the failure rollback into two paths:

  1. **Detect "intervening clear" via `activePluginIdRef.current
     === null && prevActiveId !== null`.** `onCleared` always
     nulls the active id as its last action; our deferred
     `setActivePlugin` never ran in the failure branch. So the
     null-while-prev-not-null state is the smoking gun for an
     intervening clear during the await.

  2. **On detection, surgically remove only our optimistic
     entry and only its `@<target>`.** Locate the entry by
     `insertionId` (added to `TrackedInsertion` as an optional
     field, forwarded by `reconcileInsertions` so the id
     survives offset shifts) — this disambiguates the case
     where the user picked the same plugin from the @-popover
     more than once during the await window. Splice that entry
     out and run `updateDraft((d) => stripPluginInsertedTokens(
     d, [ourEntry]))` so the draft loses `@<target>` and any
     remaining tracked entries (the in-flight target would have
     no others, but a co-pending second pick could) get their
     offsets reconciled. `activePluginIdRef` stays at `null` —
     `onCleared`'s truth, since no chip is mounted.

  The "no intervening clear" branch is the round 7/8 path:
  restore `prevEntries`/`prevActiveId` wholesale and rewrite
  the draft only if `draftRef.current === postInsertDraft`
  (no user keystrokes during the await).

Regression coverage (additions):

  - `apps/web/tests/components/ChatComposer.plugin-clear-prunes-draft.test.tsx`
    — 18 integration specs total (17 prior + 1 new round-10):
    * `@-popover pick A → @-popover pick B (apply pending) →
      clear A's chip → resolve B with 500 → assert no orphan
      @<target>, no orphan @A, no chip mounted, no stale
      tracker entries`. Uses a deferred `Promise<Response>` so
      the apply stays in flight while the chip-clear is fired,
      then resolves with a 500 to drive the failure path. Pre-
      fix this would resurrect Airbnb's stale entry AND leave
      `@SecondPlugin` orphaned in the draft.

PluginsSection.tsx is unchanged. The host-local tracking +
draft-update chokepoint + parser-aligned boundaries + deferred
active-plugin scoping + transactional applyById + intervening-
clear-aware rollback + filtered `onCleared` keep the cross-
component contract identical to main — only ChatComposer touches
behavior, plus the utils module and two `inlineMentions` exports.

Validation:
  - pnpm exec vitest run tests/utils/pluginInsertionTracking.test.ts → 36/36 passed
  - pnpm exec vitest run tests/components/ChatComposer.plugin-clear-prunes-draft.test.tsx → 18/18 passed
  - pnpm exec vitest run -c vitest.config.ts (full apps/web suite, 228 files) → 2202/2202 passed
  - pnpm --filter @open-design/web typecheck → green
  - pnpm guard → green
2026-05-30 17:16:24 +00:00
..
analytics feat(analytics): emit file_upload_result from all three upload entries (#2459) 2026-05-21 18:28:56 +08:00
artifacts fix(web): parse ask-question blocks as question-form alias (#1194) (#3053) 2026-05-27 06:34:36 +00:00
components fix(web): prune draft tokens when the plugin chip strip clears (#2881) (#3356) 2026-05-30 17:16:24 +00:00
edit-mode feat(web): merge Draw + Screenshot into one Studio mark tool (#3081) (#3277) 2026-05-29 06:51:38 +00:00
helpers refactor(web): split global CSS by ownership (#2609) 2026-05-25 05:48:28 +00:00
hooks fix(web): coalesce chokidar rewrite bursts before refreshing files (#2326) 2026-05-20 11:12:53 +08:00
i18n test(i18n): lock zh-CN to tier-1 explicit translation parity (#2920) 2026-05-25 20:08:41 +08:00
lib fix(web): clarify finalize BYOK requirements for Local CLI users (#3041) 2026-05-27 06:20:33 +00:00
media fix(media): hide OpenAI OAuth-only image credentials (#3308) 2026-05-30 04:12:10 +00:00
observability feat(observability): web lifecycle telemetry + stable installationId migration (#2527) 2026-05-21 15:37:48 +08:00
providers fix(web): send Anthropic proxy image attachments (#3273) 2026-05-30 04:47:47 +00:00
runtime feat(web): add staged preview feedback during generation (#3227) 2026-05-30 13:59:49 +00:00
state fix(media): hide OpenAI OAuth-only image credentials (#3308) 2026-05-30 04:12:10 +00:00
styles fix(web): theme home hero select menu (#3309) 2026-05-30 03:53:42 +00:00
utils fix(web): prune draft tokens when the plugin chip strip clears (#2881) (#3356) 2026-05-30 17:16:24 +00:00
analytics-app-version.test.tsx fix(analytics): app_version=0.0.0 + media providers clicks + lock run_finished error_code (#2453) 2026-05-20 21:50:11 +08:00
analytics-configure-globals.test.ts feat(analytics): ship PostHog v2 event schema (#2285) 2026-05-20 13:04:20 +08:00
analytics-identity.test.ts feat(analytics): PostHog product analytics (P0 events, consent-gated, packaged) (#1428) 2026-05-12 22:32:42 +08:00
analytics-scrub.test.ts feat(analytics): PostHog product analytics (P0 events, consent-gated, packaged) (#1428) 2026-05-12 22:32:42 +08:00
api-attachment-context.test.ts fix(web): send Anthropic proxy image attachments (#3273) 2026-05-30 04:47:47 +00:00
App.test.ts fix(web): suppress autosave indicator for draft-only Connector key edits (#1232) 2026-05-11 20:52:45 +08:00
comments.test.ts feat(comments): add comment attachment API (#2869) 2026-05-25 07:24:21 +00:00
host-boundary.test.ts refactor desktop host bridge (#2246) 2026-05-19 18:27:05 +08:00
quickSwitcherRecents.test.ts feat(web): add Cmd/Ctrl+P quick file switcher (#556) 2026-05-06 10:31:50 +08:00
router-marketplace.test.ts [codex] Add global onboarding flow without AMR (#2272) 2026-05-19 22:00:40 +08:00
router.navigate.test.tsx fix(web/router): defer popstate dispatch to microtask (#2490) 2026-05-29 09:37:55 +00:00
router.test.ts Garnet hemisphere (#1702) 2026-05-14 21:12:50 +08:00
sidecar-proxy.test.ts Enable LAN web dev access (#1947) 2026-05-19 17:50:50 +08:00