Skip to content

[lexical-code-core] Bug Fix: Shift+Tab outdents a code line when the caret is at column 0 - #8992

Closed
LeSingh1 wants to merge 1 commit into
facebook:mainfrom
LeSingh1:fix/code-outdent-column-zero
Closed

[lexical-code-core] Bug Fix: Shift+Tab outdents a code line when the caret is at column 0#8992
LeSingh1 wants to merge 1 commit into
facebook:mainfrom
LeSingh1:fix/code-outdent-column-zero

Conversation

@LeSingh1

@LeSingh1 LeSingh1 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Description

$getCodeLines deliberately discards a trailing line when the selection ends
exactly at its start:

// Discard the last line if the selection ends exactly at the
// start of the line (no real selection)
const lastPoint = $createPoint(lastLine[0].getKey(), 0, 'text');
if (!selectionEnd.is(lastPoint)) {
  lines.push(lastLine);
}

For a collapsed caret at column 0 that condition is always true, and the
caret's line is the only line — so codeLines comes back empty and control
falls into the collapsed special case, which only ever handles indent:

if (codeLinesLength === 0 && selection.isCollapsed()) {
  if (type === INDENT_CONTENT_COMMAND) {
    selection.insertNodes([$createTabNode()]);
  }
  return true;   // OUTDENT: returns handled, having done nothing
}

So Shift+Tab with the caret at the very start of an indented code line silently
did nothing, while the identical keystroke one column to the right outdented
normally. It also returns true, so the command is reported as handled and no
lower-priority handler gets a chance.

Column 0 is the position the caret naturally lands on — Home, ArrowUp from
the line below, or clicking the left gutter — so this is the common case, not
an edge one.

Resolve the caret's own line from the anchor in that branch and apply the same
rule the outdent loop uses: strip a leading TabNode, or tabSize leading
spaces when the extension is configured for space indentation. An element-typed
anchor (the caret on a blank line) has no line to outdent and still no-ops.

The existing coverage steps around this: can outdent at arbitrary points in the line (with tabs) uses codeText.select(1, 1), and the outdent half of
can indent/outdent with collapsed selection at start of line (with tabs)
builds a non-collapsed selection spanning the tab, despite its name.

Test plan

New packages/lexical-code-core/src/__tests__/unit/CodeOutdentCollapsedAtLineStart.test.ts
— a separate file rather than CodeIndentation.test.ts, which #8986 is already
editing. This PR is independent of #8986: it touches a different function in
CodeIndentation.ts and the two apply cleanly in either order.

Two cases pin the fix; two are controls that pass before and after (outdent
from a non-zero column, and the no-op on an unindented line with no tabSize).

Before

Verified by restoring CodeIndentation.ts to its pre-fix contents with the new
tests in place:

$ npx vitest run packages/lexical-code-core/src/__tests__/unit/CodeOutdentCollapsedAtLineStart.test.ts

     × outdents when the caret is collapsed at column 0 (on the TabNode) 10ms
     × outdents when the caret is collapsed at column 0 of the code text 1ms
     ✓ still outdents from a non-zero column (unchanged behaviour) 1ms
     × strips a space indent from column 0 when tabSize is configured 1ms
     ✓ is still a no-op on an unindented line 1ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯
AssertionError: expected '\thello' to be 'hello' // Object.is equality
AssertionError: expected '\thello' to be 'hello' // Object.is equality
AssertionError: expected '  hello' to be 'hello' // Object.is equality

      Tests  3 failed | 2 passed (5)

After

$ npx vitest run packages/lexical-code-core packages/lexical-code packages/lexical-code-prism packages/lexical-code-shiki

 Test Files  12 passed (12)
      Tests  273 passed | 1 skipped (274)

…caret is at column 0

## Description

`$getCodeLines` deliberately discards a trailing line when the selection ends
exactly at its start:

```ts
// Discard the last line if the selection ends exactly at the
// start of the line (no real selection)
const lastPoint = $createPoint(lastLine[0].getKey(), 0, 'text');
if (!selectionEnd.is(lastPoint)) {
  lines.push(lastLine);
}
```

For a **collapsed** caret at column 0 that condition is always true, and the
caret's line is the only line — so `codeLines` comes back empty and control
falls into the collapsed special case, which only ever handles indent:

```ts
if (codeLinesLength === 0 && selection.isCollapsed()) {
  if (type === INDENT_CONTENT_COMMAND) {
    selection.insertNodes([$createTabNode()]);
  }
  return true;   // OUTDENT: returns handled, having done nothing
}
```

So Shift+Tab with the caret at the very start of an indented code line silently
did nothing, while the identical keystroke one column to the right outdented
normally. It also returns `true`, so the command is reported as handled and no
lower-priority handler gets a chance.

Column 0 is the position the caret naturally lands on — `Home`, `ArrowUp` from
the line below, or clicking the left gutter — so this is the common case, not
an edge one.

Resolve the caret's own line from the anchor in that branch and apply the same
rule the outdent loop uses: strip a leading `TabNode`, or `tabSize` leading
spaces when the extension is configured for space indentation. An element-typed
anchor (the caret on a blank line) has no line to outdent and still no-ops.

The existing coverage steps around this: `can outdent at arbitrary points in
the line (with tabs)` uses `codeText.select(1, 1)`, and the outdent half of
`can indent/outdent with collapsed selection at start of line (with tabs)`
builds a *non-collapsed* selection spanning the tab, despite its name.

## Test plan

New `packages/lexical-code-core/src/__tests__/unit/CodeOutdentCollapsedAtLineStart.test.ts`
— a separate file rather than `CodeIndentation.test.ts`, which facebook#8986 is already
editing. This PR is independent of facebook#8986: it touches a different function in
`CodeIndentation.ts` and the two apply cleanly in either order.

Two cases pin the fix; two are controls that pass before and after (outdent
from a non-zero column, and the no-op on an unindented line with no `tabSize`).

### Before

Verified by restoring `CodeIndentation.ts` to its pre-fix contents with the new
tests in place:

```
$ npx vitest run packages/lexical-code-core/src/__tests__/unit/CodeOutdentCollapsedAtLineStart.test.ts

     × outdents when the caret is collapsed at column 0 (on the TabNode) 10ms
     × outdents when the caret is collapsed at column 0 of the code text 1ms
     ✓ still outdents from a non-zero column (unchanged behaviour) 1ms
     × strips a space indent from column 0 when tabSize is configured 1ms
     ✓ is still a no-op on an unindented line 1ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯
AssertionError: expected '\thello' to be 'hello' // Object.is equality
AssertionError: expected '\thello' to be 'hello' // Object.is equality
AssertionError: expected '  hello' to be 'hello' // Object.is equality

      Tests  3 failed | 2 passed (5)
```

### After

```
$ npx vitest run packages/lexical-code-core packages/lexical-code packages/lexical-code-prism packages/lexical-code-shiki

 Test Files  12 passed (12)
      Tests  273 passed | 1 skipped (274)
```
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

@LeSingh1 is attempting to deploy a commit to the Meta Open Source Team on Vercel.

A member of the Team first needs to authorize it.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 9, 2026
@LeSingh1

Copy link
Copy Markdown
Contributor Author

Consolidated into #9056 with the other PRs that share this defect, per @etrepum's note on #9027 and @mayrang's on #9035. Same fix and same tests, one review.

@LeSingh1 LeSingh1 closed this Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant