The row that split in half
My own RTL fix caused the next bug. Putting dir="auto" on an option's text also flipped what text-align: start meant for its box, leaving a row-width gap.
By Meet Modidir="auto" sets the element's resolved direction, and text-align: start resolves against that direction. So putting dir="auto" on a flex child to fix its punctuation also flips which physical edge its text aligns to, and the child drifts to the opposite end of the row from its siblings. The fix is to put the direction on the row, not on the string.
This bug was mine. I shipped it one commit after the fix it came from, which is the most honest kind of bug to write about.
The setup
A week earlier I had fixed trailing punctuation landing on the wrong side in an RTL layout by putting dir="auto" on the places where content enters the DOM. That is written up in the full stop that moved. One of those places was the text inside a multiple-choice option.
A quiz option row is the shape you would expect: a fixed-width badge holding A, B, C or D, and the option text beside it taking the remaining space.
<div className="flex items-center gap-3">
<span className="shrink-0 badge">{letter}</span>
<span className="flex-1 text-start" dir="auto">{option.text}</span>
</div>What shipped
In an Arabic app with English options, the badge sat pinned at the right edge of the row and the text sat at the left edge, with the whole width of the row empty between them. Not subtly off. Comically off.
Why
The row is RTL, so flex lays its children out right to left: badge first, meaning rightmost, then the flex-1 text filling what remains to the left. That part is correct and is what you want.
The text span is flex-1, so its box spans the whole remaining width. Inside that box, text-align: start decides where the glyphs sit. Before my change the span inherited RTL, start meant the right edge, and the text sat snug against the badge. After my change dir="auto" read the first strong character, found a Latin letter, and resolved the span to LTR. start now meant the left edge of a box that stretches all the way across the row. The text walked to the far wall.
The fix, and why auto is wrong here
Direction belongs on the row, so the badge and the text resolve together and stay adjacent.
// Direction on the row: badge and text resolve as one unit.
const dir = isRtlText(option.text) ? "rtl" : "ltr";
<div className="flex items-center gap-3" dir={dir}>
<span className="shrink-0 badge">{letter}</span>
<span className="flex-1 text-start">{option.text}</span>
</div>It cannot be dir="auto" on the row. The badge is the row's first child and A, B, C and D are strong left-to-right characters, so auto would read every row in the quiz as left-to-right regardless of what the option says. The one thing auto looks at is the one thing that is always Latin.
So the direction comes from a helper that counts letters by script instead of taking the first strong character. That also handles the string auto gets wrong anyway: "Photosynthesis is عملية" starts with a Latin letter and is mostly Arabic.
Fix every row of that shape, not the reported one
The report named one screen. The shape existed in five: the quiz renderers, the result drawer, the review shell's option row, and the single and multi-select option lists. Two of those were still using text-left rather than text-start, which is exactly why nobody had noticed them ignoring RTL in the first place.
The takeaway
Direction is not a property of a string. It is a property of a box, and every logical value inside that box, text-align: start, margin-inline-start, border-inline-end, is measured against it. Set direction on the smallest element that contains everything which has to agree. For a sentence that is the sentence. For a row where a badge and its text must stay side by side, it is the row.
More Posts
Two services, one topic, either deploy order
A shared topic is only safe if the old consumer ignores what the new producer adds. One field with a default, and either side can roll back alone.
Scale on what's queued and you'll remove the pod mid-job
An autoscaler counting queued work drops to zero the moment pods claim it, so it scales into running jobs and each removal hands the work back to start over.
The model was starting and the first job didn't know
A state flag set inside the background thread instead of before launching it, so the caller that asked for the model read 'stopped' and took the slow path.