Skip to content

fix(richtext): text reads the way it is written, in either document direction - #7723

Draft
potatowagon wants to merge 6 commits into
facebook:mainfrom
potatowagon:richtext-rtl-block-direction
Draft

potatowagon wants to merge 6 commits into
facebook:mainfrom
potatowagon:richtext-rtl-block-direction

Conversation

@potatowagon

@potatowagon potatowagon commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

The bug

Text in RichTextEditor is laid out by the direction of the document around it, not by the direction of the text itself. In a left-to-right document a Hebrew paragraph flushes left, and its arrow keys move the caret the wrong way — press Right and the caret goes left. In a right-to-left document every English paragraph, list item, table cell and code line does the mirror.

Google Docs and Lexical's own playground read each paragraph the way its own text reads. A Hebrew blockquote here also draws its bar and indent on the wrong edge.

Repro

  1. Open the RichTextEditor → Playground story, left-to-right (the default).
  2. Paste a Hebrew paragraph: פסקה בעברית לבדיקת כיוון הטקסט.
  3. It flushes left. Click into it and press Right: the caret moves left.

Then flip the document to right-to-left and try the same in an English list item, a table cell and a code block.

Why

Lexical marks every top-level block dir="auto" and writes nothing below that. editorTheme.ts then overrode the result on every block:

providerDirection: {
  direction: 'inherit',
},

dir="auto" gives a block two things: the computed direction its first strong character asks for, and unicode-bidi: isolate. An author declaration beats the UA's, so the override took the first and left the second. The Hebrew text stayed an isolated run, reordering right-to-left inside itself, while its paragraph became left-to-right — and text-align: start and the logical insets resolve against direction, so the text flushes left, the quote bar lands on the wrong edge, and caret motion follows the paragraph rather than the text.

Measured in Chromium, the same markup with and without the override:

block direction border-inline-start lands
Hebrew, with the override ltr left ❌
Hebrew, without it rtl right ✅

Below the top level there was no override to blame — Lexical simply writes no dir there, so a list item or table cell inherits the document's direction and an English one in a right-to-left document is a left-to-right run in a right-to-left box. Same symptom, one level down. Measured on the playground in a right-to-left document: 18 of 18 list items and 8 of 8 cell paragraphs disagreed with their own text.

Why CSS alone cannot fix it

A :dir() rule fixes the Hebrew-in-English case and nothing else. Under dir="auto" a block of English, a block of digits, an image-only block and an empty block are all :dir(ltr), so a rule that catches the first catches the other three. Measured in Chromium inside a right-to-left document:

block :dir(rtl) rule only adding :dir(ltr)
An English paragraph. rtl ❌ ltr ✅
empty (<br>) rtl ✅ ltr ❌ caret jumps to the left
image only rtl ✅ ltr ❌ resize handle flips side

The last two are not hypothetical: an earlier draft of this PR used the symmetric CSS rule and broke the resize handle follows the drag in a right-to-left document.

Reaching for unicode-bidi: plaintext on prose is not the answer either. It makes text-align: start follow the content's own direction, so the text lands correctly — but logical properties still resolve against direction, so a Hebrew blockquote in an English document would flush its text right while keeping its bar on the left.

The fix

Write the direction out, which is the one thing that can express "this block has no direction of its own".

BlockDirectionPlugin gives each prose container an explicit dir from getTextDirection() of its own text — a public Lexical export — and leaves a container with no strong character alone:

const own = getTextDirection(node.getTextContent());
if (node.getDirection() !== own) {
  node.setDirection(own);
}

The theme then reads the three states apart:

blockDirection: {
  direction: {
    default: null,                   // dir="ltr" / dir="rtl": the UA rule stands
    ':is([dir="auto"])': 'inherit',  // no direction of its own: the document's
  },
},

A Hebrew paragraph reads right-to-left and an English one left-to-right, wherever each sits, and an empty paragraph still keeps its caret on a right-to-left document's start edge.

Lexical serializes dir, so a stored document reopens the way it was written, and $setDirectionFromDOM already preserves the attribute when HTML is pasted in.

Prose carries a direction; its container orders it

ParagraphNode, HeadingNode, QuoteNode and ListItemNode take part, at any depth — so a paragraph inside a table cell does too.

Their containers do not, because they order their contents rather than reading as one piece of prose:

  • A list orders its markers and indent in the document's direction, so the <ul> keeps it. A single dir on the <ul> would also impose the first item's direction on every item — under a Hebrew first item, Buy milk. would render .Buy milk — which a direction per item cannot do.
  • A table reads its columns in the document's order, so the table, its rows and its cell boxes keep it. The paragraph inside a cell is prose and takes its own.

Containers take the document's direction unconditionally, and the plugin clears a direction that arrives on one. Nothing in the editor writes a dir onto a container, but $setDirectionFromDOM does when HTML is pasted and an older stored document can carry one, so a list pasted as <ul dir="rtl"> would otherwise keep its markers and indent on the right in an English document.

  • A fenced code block keeps it as a frame, but not for its text. The node tree has no per-line node to carry a direction, but CSS does not need one: unicode-bidi: plaintext puts each preserved line in its own bidi paragraph, so every line reads the way its own first strong character reads. A Hebrew comment line reads right-to-left while const x = 1; keeps its semicolon at the end, in either document — which no single direction on the block could have given.

This is spec:AST-061 FR6 narrowed to prose containers; the comments in editorTheme.ts and BlockDirectionPlugin.tsx and the parity tests now state it.

Where the write happens matters for undo

Typing inside an existing paragraph dirties its text node, not the container, so no element transform runs for that keystroke. Doing the work from an update listener instead costs an editor.update of its own, which becomes its own history entry: undo restores the stale direction, the listener sweeps again and pushes a new entry, and undo never gets past it (- then Hello, then Ctrl+Z, stops at H).

A TextNode transform now writes the direction inside the update that changed the text, so it rides in that keystroke's own history entry. The update listener remains only for a whole-state replacement, which runs no transforms and is what RichTextView does on every value change, and it is tagged history-merge. Writing the direction settles the container, so the sweep does not loop.

Relationship to #7001

This narrows #7001 rather than reverting it. That PR fixed a real problem, and its description states it exactly:

in a left-to-right product a Hebrew or Arabic paragraph flips to the right … Readers switching between the two see blocks jump sides, and a mixed-language document in RichText reads as a ragged mix of alignments.

Switching a page between Markdown and RichTextEditor genuinely did make blocks jump sides, and that is worth fixing. The scope is what I would like to revisit, on two points.

Its stated invariant does not hold as written. #7001 says "text inside a block still lays out by the Unicode bidi algorithm, so a Hebrew phrase inside an English sentence still reads correctly". A Hebrew phrase inside a sentence does, but direction is itself an input to that algorithm: it sets the paragraph embedding level. Overriding it while dir="auto" keeps unicode-bidi: isolate leaves the block's text an isolated right-to-left run inside a left-to-right paragraph, where alignment, logical insets and caret motion all disagree with the text. That intermediate state is worse than either behaviour on its own.

Parity is the renderer's requirement, not the field's. RichTextView and core Markdown show authored content, and matching them block for block is clearly right. RichTextEditor has a typist. A field whose chrome overrules the direction of what someone types cannot be correct for a bilingual author, which is why Google Docs and Lexical's playground both let the block win.

So this PR keeps #7001's parity everywhere it is about rendering — core Markdown is untouched, RichTextView and the editor still agree with each other, and a list's markers, a table's columns and a code block's frame all still take the document's direction — and drops it only for text that has a direction of its own.

One correction worth recording: I first left the fenced code block alone on the grounds that core Markdown rendered it the same way. Measured, it does not — in a right-to-left document core Markdown's pre starts its code at the left edge while RichText's started at the right. The two surfaces already disagreed, so leaving it was not protecting parity. They agree now.

Worth noting for the test strategy: #7001's RTL coverage flips the whole surface to direction: rtl and asserts both surfaces report rtl. Both the old behaviour and this one pass that, because a uniform-direction document cannot distinguish them. The mixed-direction case is the one that separates them, and this PR adds it.

@cixzhang, since this is your area and your spec reading — happy to take the other decision if FR6 is meant to bind the editor as strictly as the renderer. In that case the minimum fix is still needed: the override has to neutralise dir="auto" rather than take half of it, or the caret keeps walking backwards through right-to-left text.

Tests

MarkdownParitySandbox.a11y.chromium.spec.ts asserted that every block takes the provider direction, which is the behaviour being fixed. It is now split and tightened:

  • markdown blocks take the provider direction (ltr|rtl) — core Markdown sets no dir, so it is unchanged, and this keeps the base's nested tr, th, td, li, p check.
  • a rich text block reads the way its own text reads (ltr|rtl) — the Hebrew block is rtl and an English block is ltr in either document direction; the thematic break, which has no direction of its own, follows the document; so do the table's parts.
  • each line of a fenced code block reads its own way (ltr|rtl) — the block is unicode-bidi: plaintext, its code line reads const x = 1; starting at the left edge in either document, and its Hebrew comment line reads right-to-left starting at the right. Characters are read out of client rects and grouped into visual lines, so syntax-highlighting spans cannot hide a reordering.
  • a list orders in the document direction and its items read their own (ltr|rtl) — the <ul> and the code block stay dir="auto" and compute to the document's direction, while the Hebrew item is rtl and the English one ltr, which is what keeps Buy milk. reading Buy milk..
  • a table orders in the document direction and its cells read their own (ltr|rtl) — the cell box follows the document so columns keep their order; the paragraph inside reads the way the cell's own text reads.

New BlockDirection.a11y.chromium.spec.ts is the undo guard, in a real browser: - + Hello + Ctrl+Z and 123 + שלום + Ctrl+Z both walk back to an empty document, and a paragraph picks up auto → ltr → rtl as the text typed into it changes. Both undo tests fail on the commit before the fix and pass after it.

New blockDirection.test.tsx covers the attribute itself in jsdom: ltr/rtl/auto written onto the right containers, the same directions in RichTextView, a rewrite when the view is handed a new document, a list whose three items come out rtl, ltr and none, the list, table and code block left to the document, and a stored <ul dir="rtl"> giving its direction up while its English item keeps ltr (this one fails on the commit before the fix).

Run against a built Storybook:

  • packages/richtext chromium suite: 96 passed.
  • packages/richtext unit tests: 601 passed.
  • eslint src: 0 errors. tsc --noEmit: no new errors against a stashed baseline. check:changesets, check:sync, check:use-client and check:package-boundaries: clean.

Commits

  1. 006f870 — the inverted caret for a top-level Hebrew paragraph.
  2. c0a0e9e — the mirror case, via an explicit dir per block rather than CSS.
  3. 0ddeb45 — review feedback: the direction write no longer costs an undo step.
  4. 7c17e20 — list items and table cells, at any depth.
  5. fcf55a8 — each line of a fenced code block.
  6. e0fcff5 — a container gives up a direction that arrives with pasted HTML or a stored document.

…-right document

A block kept the surface's `direction` while Lexical's `dir="auto"`
reordered its glyphs, so a Hebrew paragraph in an English document sat as
a right-to-left run inside a left-to-right line box: it flushed left, its
quote bar, list markers and indent sat on the wrong edge, and the caret
walked backwards -- press Right and it moved left. Google Docs and
Lexical's own playground both read that paragraph right-to-left.

A block now follows the surface until its own text is strongly
right-to-left, which `:dir(rtl)` on a `dir="auto"` block detects. CSS
cannot tell a strongly left-to-right block from one holding digits,
punctuation, an image or nothing at all, so everything else still follows
the document and an empty paragraph keeps its caret on the document's
start edge. Table parts are exempt: their columns are read in the
document's order.
@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 23, 2026 2:09am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 20, 2026
@astracat-bot

astracat-bot Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
📝 Draft — not reviewed automatically (for maintainers only) Oct 25, 2026, 6:47 AM UTC

@github-actions github-actions Bot added community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge labels Oct 20, 2026
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Preview availability: Storybook and Sandbox are not both ready on the exact-head Vercel preview. CI concluded failure.

Modified Components

RichTextEditor (@astryxdesign/richtext)
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 661 -
Complexity N/A Very High (65) -
RichTextView (@astryxdesign/richtext)
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 180 -
Complexity N/A Medium (15) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/richtext N/A 1.4KB 746B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

40 of 482 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 754,077
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,188
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
RichTextEditor Markdown parity: side by side probe dark 95,635
RichTextEditor Markdown parity: side by side probe light 90,403
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232
ProgressBar With Target Mark probe dark 7,895
ProgressBar With Target Mark probe light 7,895
Markdown Default neutral dark 7,573
Markdown Default neutral light 7,566

and 20 more.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot 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.

Thanks for chasing down the inverted caret: it is real, and this change fixes it for top-level Hebrew paragraphs. Approval waits on a maintainer decision. spec:AST-061 FR6 currently says a surface does not choose a block's direction from its text, so a maintainer needs to either update FR6 to allow this exception (and say whether core Markdown follows it) or keep FR6 as written. Separately, these need fixing whichever way that goes:

  • A list whose first item is Hebrew now turns every item right-to-left in an English document, so Buy milk. renders as .Buy milk; keeping ul/ol on the surface direction avoids it.
  • A fenced code block whose first line is a Hebrew comment turns right-to-left, so const x = 1; renders as ;const x = 1 and the copy button covers the first line; keeping code on the surface direction avoids it.
  • The changeset says such a block reads right-to-left "wherever it sits", but a Hebrew item in a list that starts in English is unchanged, and the parity test should keep its nested-direction check for core Markdown.

[Automated review]

// its own text is strongly right-to-left.
const block = (...styles: Array<stylex.StyleXStyles>): string =>
stylex.props(...styles, editorTheme.providerDirection).className ?? '';
stylex.props(...styles, editorTheme.blockDirection).className ?? '';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

block() also builds the ul/ol classes (lines 521-522) and the fenced-code class (line 548). Lexical gives list items no dir, so a list whose first item is Hebrew turns every item right-to-left (Buy milk. renders as .Buy milk). A code block whose first line is a Hebrew comment renders const x = 1; as ;const x = 1, with the copy button over its first line. Keeping lists and code on the surface direction, as tablePart does, avoids both.

const otherDirection = geometry.blocks
.filter(block => block.direction !== direction)
.map(block => block.key);
expect(otherDirection, MARKDOWN).toEqual([]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This now checks only Markdown's top-level blocks. The base also checked tr, th, td, li, p inside Markdown, whose output this PR does not change. Please keep that nested check, and consider adding a Hebrew-first list and a Hebrew-commented code block to the fixture so the RichText rule is exercised on lists and code too.

…cument

Follow-up in the same change: the mirror case. An English paragraph in a
Hebrew document was broken the same way a Hebrew paragraph in an English
document was, and `:dir()` alone cannot fix it -- under `dir="auto"` a
block of English, digits, an image and an empty line are all `:dir(ltr)`,
so a CSS rule that catches the first catches the other three and moves an
empty paragraph's caret to the wrong edge.

BlockDirectionPlugin writes the direction out instead: each top-level
block gets `dir="ltr"` or `dir="rtl"` from `getTextDirection` of its own
text, and keeps `dir="auto"` when it has no strong character, which the
theme styles as the document's direction. Lexical serializes the value
and $setDirectionFromDOM already preserves it on paste, so a stored
document reopens the way it was written. The plugin runs in the editor
and the view, so both read a document the same way.

The RTL image-resize test moves its image into the document's Hebrew
paragraph: the last paragraph is English, so under this change it reads
left-to-right even in a right-to-left document, and an image in it has
its handle on the right.
@potatowagon potatowagon changed the title fix(richtext): a right-to-left block reads right-to-left in a left-to-right document fix(richtext): a block reads the way its own text reads, in either document Oct 20, 2026

@astracat-bot astracat-bot 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.

Thanks for the careful write-up: the inverted caret is real on the base, and this head fixes it for top-level Hebrew paragraphs. Approval waits on a maintainer decision, because spec:AST-061 FR6 currently says a surface does not choose a block's direction from its text, so a maintainer needs to amend FR6 and its verification row (and say whether core Markdown follows) or keep it as written. Independently of that decision, these need fixing:

  • Undo gets stuck once a keystroke changes a block's own direction: typing - Hello and pressing Ctrl+Z stops at H instead of removing the list, because the direction sweep in BlockDirectionPlugin runs as its own history step.
  • A list whose first item is Hebrew, or a code block whose first line is a Hebrew comment, still turns right-to-left as a whole, so Buy milk. renders as .Buy milk and const x = 1; as ;const x = 1.
  • The Markdown half of the FR6 parity test should keep its nested tr, th, td, li, p check, since this change does not touch Markdown.

[Automated review]

Comment on lines +105 to +121
editor.registerUpdateListener(({editorState}) => {
const stale = editorState.read(() =>
$getRoot()
.getChildren()
.some(
node =>
$isOwnDirectionBlock(node) &&
node.getDirection() !== $ownDirection(node),
),
);
if (stale) {
editor.update(() => {
$getRoot().getChildren().forEach($writeOwnDirection);
});
}
}),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This follow-up editor.update lands as its own undo step whenever a keystroke inside an existing text node changes the block's first strong character (Lexical runs no element transform for that keystroke, so this listener is what fixes the direction). Undoing past it restores the stale state, the listener sweeps again and pushes a new step, so undo never gets further: in the Default story, - + Hello then Ctrl+Z ×3 leaves H, and 123 + שלום then Ctrl+Z ×4 leaves 123 ש; the base undoes both to an empty editor. Writing the direction inside the update that caused it (for example a TextNode transform that updates its top-level block) and tagging any whole-state sweep history-merge would avoid it.

Comment on lines +58 to +64
const TEXT_BLOCKS: ReadonlyArray<Klass<ElementNode>> = [
ParagraphNode,
HeadingNode,
QuoteNode,
ListNode,
CodeNode,
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ListNode and CodeNode take the direction of their whole text, so a list whose first item is Hebrew turns every item right-to-left (Buy milk. renders .Buy milk), and a code block whose first line is a Hebrew comment renders const x = 1; as ;const x = 1, with the copy button over the comment. Leaving lists and code on the surface direction, as tables are, avoids both.

@potatowagon
potatowagon marked this pull request as draft October 20, 2026 15:33
… direction write out of history

Review feedback from astracat-bot on facebook#7723.

A list and a fenced code block hold several lines under one element, so
one `dir` on the element imposes the first line's direction on all of
them: under a Hebrew first item `Buy milk.` rendered `.Buy milk`, and
under a Hebrew first comment `const x = 1;` rendered `;const x = 1`.
They leave `dir="auto"` now, which the theme styles as the document's
direction and under which the browser reads each line on its own.
ParagraphNode, HeadingNode and QuoteNode are read as one piece of prose
and keep their own direction.

The direction write also cost an undo step. Typing inside an existing
text node dirties the text node, not the block, so no element transform
ran and the update listener was doing the work -- in an `editor.update`
of its own, which became its own history entry. Undo restored the stale
direction, the listener swept again, and undo never got past it: `- ` +
`Hello` then Ctrl+Z left `H`. A TextNode transform now writes the
direction inside the update that changed the text, and the whole-state
sweep that RichTextView needs is tagged `history-merge`.

The Markdown half of the FR6 parity test keeps its nested
`tr, th, td, li, p` check: this change does not touch core Markdown.
@potatowagon

Copy link
Copy Markdown
Contributor Author

Thanks — all three are real, and all three are fixed in 0ddeb45. Taking them in turn.

Lists and fenced code blocks. You are right, and the fix is the one you suggested. ListNode and CodeNode are out of the set that carries its own direction, so they keep dir="auto" and the theme gives them the document's direction. The reasoning I have written into the plugin is that a list and a code block are several lines under one element, so a single dir on the element imposes the first line's direction on the rest — the same reason a table is out. ParagraphNode, HeadingNode and QuoteNode are read as one piece of prose and keep theirs.

Worth noting what the browser then does, because it is better than "no direction handling": under dir="auto" a <ul> gets unicode-bidi: isolate and a <pre> gets plaintext, so a Hebrew item still reorders correctly as a right-to-left run, a Hebrew comment line still reads right-to-left, and Buy milk. and const x = 1; are untouched. Measured in Chromium, with the items' visual character order read out of client rects rather than from the DOM.

Per-item direction inside a list is a real improvement and is reachable — the reconciler emits dir at any depth once __dir is set — but it is a bigger change than this fix, so I have noted it in the PR body as a follow-up rather than smuggling it in here.

Undo. Good catch, and your diagnosis was exactly right, including why: typing inside an existing text node dirties the text node and not the block, so no element transform ran for that keystroke and the update listener was doing the work — in an editor.update of its own, which became its own history entry.

Fixed the way you suggested: a TextNode transform walks up to the top-level block and writes the direction inside the update that changed the text, so it rides in that keystroke's own history entry. The update listener survives only for a whole-state replacement, which runs no transforms and is what RichTextView does on every value change, and it is now tagged history-merge.

Both of your repros are now regression tests in a new BlockDirection.a11y.chromium.spec.ts, in a real browser: - + Hello + Ctrl+Z and 123 + שלום + Ctrl+Z both walk back to an empty document. I checked they fail on the previous commit of this branch and pass on its head, so they are guarding the thing you found rather than just passing.

The parity test's nested check. Restored. The Markdown half keeps the base's tr, th, td, li, p check, since this change does not touch core Markdown. I also took the fixture suggestion, but through the markdown-serializers story rather than the shared parity fixture — a new a Hebrew-first list and code block keep the document direction (ltr|rtl) fills the textarea with a Hebrew-first list and a Hebrew-commented code block and asserts both stay dir="auto", compute to the document's direction, and leave Buy milk. reading Buy milk.. Adding those blocks to MARKDOWN_PARITY_BLOCKS instead would have moved a shared visual baseline for a rule that only needs two blocks to exercise, which seemed like the wrong trade; happy to move them into the fixture if you would rather have them there.

Changeset. Narrowed. "Wherever it sits" now says paragraph, heading or quote, and names what keeps the document's direction.

On the FR6 decision, which is yours and not mine: the PR body sets out why I think the editor should be narrower than FR6 reads today, and I have no attachment to winning it. If FR6 is meant to bind RichTextEditor as strictly as it binds the renderer, the minimum fix is still needed — the override has to neutralise dir="auto" rather than take half of it, or the caret keeps walking backwards through right-to-left text, which is the one part of this nobody is arguing for.

Suite after the changes: 94 chromium, 600 unit, no new typecheck errors, lint and repo checks clean.

@astracat-bot astracat-bot 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.

Thanks for the careful fixes: the undo regression and the Hebrew-first list and code block cases from the last round are resolved, and the caret now follows the arrow keys in a right-to-left paragraph. Approval waits on a maintainer, because spec:AST-061 FR6 currently says content blocks take the provider's direction and that a surface does not choose a block's direction from its text, so a maintainer needs to update FR6 and its verification row to allow own-text direction for paragraphs, headings and quotes (and say whether that covers RichTextEditor only, RichTextView too, or core Markdown as well) or keep FR6 as written. Independently of that decision, one fix is needed:

  • A list, code block or table-cell paragraph that arrives with an explicit dir (pasted HTML or a stored value) now keeps it, because block() still builds ul, ol and code on the new rule: a pasted <ul dir="rtl"> renders Buy milk. as .Buy milk in an English document, so keeping those classes on the unconditional document direction, as tablePart does, and clearing dir on nested paragraphs would fix it.

[Automated review]

// the document while it still says `auto`.
const block = (...styles: Array<stylex.StyleXStyles>): string =>
stylex.props(...styles, editorTheme.providerDirection).className ?? '';
stylex.props(...styles, editorTheme.blockDirection).className ?? '';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

block() still builds ul, ol (lines 518-519) and code (line 545), so with blockDirection they now keep any explicit dir that arrives with them, which the base's unconditional direction: inherit neutralized. A list pasted as <ul dir="rtl"> into an English document turns right-to-left and renders Buy milk. as .Buy milk, and cell paragraphs pasted or stored with dir="ltr" sit flush left in a right-to-left table. Building ul, ol and code on the unconditional surface direction, as tablePart does, and clearing __dir on a paragraph that is not a root child would keep them on the document direction, as the PR intends.

@potatowagon
potatowagon marked this pull request as draft October 22, 2026 07:41
Reported on the published build: in a right-to-left document every English
list item and table cell still flushed right with its arrow keys reversed.
Only the containers written by BlockDirectionPlugin behaved, and it wrote
to top-level blocks alone.

Lexical writes no `dir` below the top level, so anything deeper simply
inherits the document's: an English list item in a Hebrew document is a
left-to-right run in a right-to-left box, which is the same inverted caret
this stack set out to fix, one level down. Measured on the playground in a
right-to-left document: 18 of 18 list items and 8 of 8 cell paragraphs
disagreed with their own text.

A prose container now carries its own direction at any depth --
paragraph, heading, quote, and list item -- so a paragraph inside a table
cell reads its own way too. Their containers still order in the document's
direction: a list's markers and indent, a table's columns.

This also makes the previous commit's list carve-out unnecessary. One
`dir` on the `ul` imposed the first item's direction on every item, which
is why it was dropped; a direction per item cannot, so a Hebrew first item
still leaves `Buy milk.` reading `Buy milk.` and an English item in a
right-to-left document now reads correctly as well.

A fenced code block keeps the document's direction: it has no per-line
node to carry one, and core Markdown's renders the same way, so changing
it belongs in a change that moves both surfaces together.
Reported on the published build: in a right-to-left document the English
text in a code block still flushed right with its arrow keys reversed.

I had left this alone on the grounds that the block has no per-line node
to carry a direction and that core Markdown renders it the same way. The
first half is true, the second is not: measured in a right-to-left
document, core Markdown's `pre` starts its code at the left edge while
RichText's started at the right, so the two surfaces already disagreed
and leaving it was not protecting parity.

CSS can do per line what the node tree cannot. `unicode-bidi: plaintext`
puts every preserved line in its own bidi paragraph, taking the direction
of its own first strong character, so a code line reads left to right
with its semicolon at the end in either document and a Hebrew comment
line still reads right to left. That is also the answer to the
Hebrew-comment case raised in review, which a single direction on the
block could not have given: measured on the base, a code block whose
first line is a Hebrew comment renders `const x = 1;` as `;const x = 1`
in a right-to-left document.
@potatowagon potatowagon changed the title fix(richtext): a block reads the way its own text reads, in either document fix(richtext): text reads the way it is written, in either document direction Oct 22, 2026
Review feedback from astracat-bot on facebook#7723.

`ul`, `ol` and the table parts were built on the conditional rule, which
leaves an explicit `dir` alone. Nothing in the editor writes one onto a
container, but $setDirectionFromDOM does when HTML is pasted, and an
older stored document can carry one -- so a list pasted as
`<ul dir="rtl">` kept its markers and indent on the right in an English
document. Measured before this change: `ul=rtl li=ltr`.

Containers are built on the unconditional document direction now, as the
tables already were, and BlockDirectionPlugin clears a direction that
arrives on a list, a code block or a table part, so the stored state says
what the document shows. The item inside still carries its own, which is
what keeps `Buy milk.` reading `Buy milk.`.

A cell paragraph that arrives with an explicit `dir` keeps it, which the
review also raised: since the previous commit a cell's prose carries its
own direction by design, so an English paragraph in a right-to-left table
reading left-to-right is the intended behaviour, not a leftover.
@potatowagon

Copy link
Copy Markdown
Contributor Author

Thanks — the pasted-container case is real and is fixed in e0fcff5. A note on each half, since the two commits that landed after your review changed what was left.

The list. Confirmed and fixed as you suggested. ul, ol and the table parts are built on the unconditional document direction now, so a dir that arrives with a paste or an older stored document cannot turn a container around. BlockDirectionPlugin also clears a direction that arrives on a list, a code block or a table part, so the stored state says what the document shows rather than carrying a value the CSS is quietly ignoring.

The symptom had already narrowed by the time I measured it: since 7c17e20 each list item carries its own direction, so a stored <ul dir="rtl"> rendered ul=rtl li=ltr — the item text read Buy milk. correctly, but the list kept its markers and indent on the right in an English document. Wrong either way, and against this PR's own rule that a list orders in the document's direction. There is a regression test for it in blockDirection.test.tsx; it fails on the commit before the fix with expected 'rtl' to be null.

The cell paragraph. This one I have deliberately left, because it is now the intended behaviour rather than a leftover. Since 7c17e20 a cell's prose carries its own direction, so an English paragraph in a right-to-left table reading and editing left-to-right is the point: it was the reported bug. In a right-to-left document, before that commit, 18 of 18 list items and 8 of 8 cell paragraphs disagreed with their own text. The cell box still takes the document's direction, so columns keep their order.

Also fixed since your review, both reported against the published build:

  • A list item or table cell in a right-to-left document still flushed right with its arrow keys reversed, because the plugin only wrote to top-level blocks. A prose container now carries its own direction at any depth (7c17e20).
  • A fenced code block did too. I had left it alone claiming core Markdown rendered it the same way; measured, it does not — core Markdown's pre starts its code at the left edge in a right-to-left document while RichText's started at the right, so the two surfaces already disagreed and leaving it was not protecting parity. unicode-bidi: plaintext gives each preserved line its own bidi paragraph, so a code line reads const x = 1; with its semicolon at the end in either document and a Hebrew comment line reads right-to-left (fcf55a8). That is also a cleaner answer to the Hebrew-comment case from your first round than a single direction on the block could have been.

Suite: 96 chromium, 601 unit, no new typecheck errors, lint and repo checks clean. The FR6 scope question is still yours.

This branch was successfully deployed

1 active deployment
Preview — e0fcff5c Deployed Oct 23, 2026 by vercel[bot]
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 Meta Open Source bot. community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant