We run a laptop and console shop in Tehran. Its storefront, ai-pars.com, is in Farsi, right to left, and built on Next.js and Payload CMS.
Most RTL write-ups cover logical properties and mirrored icons. We did all of that, and it isn't where we got hurt. Three of the five problems below reached real visitors. The other two we caught in tests or while measuring. Each one has a test now.
1. The page scrolled sideways, but only on small phones
Every page on the live site could be dragged a little to the side. Nobody on the team felt it, because our own phones are 390px or wider. We measured production later:
| Viewport | Overflow |
|---|---|
| 320px | 67px |
| 360px | 27px |
| 375px | 12px |
| 390px and up | 0 |
An earlier audit had written down «zero horizontal overflow at 390 px». At 390px that was true.
Two separate things caused it, seven weeks apart. In August, a long product title in a truncate span inside a one-column grid set the width of the whole checkout page. Flex and grid items default to min-width: auto, and a grid column with no explicit template is sized to its content. That was 66px of overflow with real titles, and 373px with the longest title our test uses. The fix was min-w-0 on flex and grid children that hold text, and an explicit grid-cols-1 base wherever columns were only declared at a breakpoint.
In September, a new theme switch pushed the header to 359px of content in a 304px box: a 143px wordmark plus four 44px buttons. No gap tuning got it under, so we moved the account icon into the menu drawer.
The RTL part is where the extra width goes. It spills off the left edge. The usual hunt, looking for any element whose getBoundingClientRect().right is past innerWidth, finds nothing. In RTL, look for a negative left instead.
The checkout bug shipped because every end-to-end test ran at Playwright's default 1280px. After it, we added a project that runs at 360x740 and seeds a cart with a title longer than any real one:
async function expectNoHorizontalOverflow(page: Page, label: string) {
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth,
)
expect(overflow, `${label} overflows by ${overflow}px at 360px`).toBeLessThanOrEqual(1)
}
That project went red on the header bug in September, and we had not acted on it. A guard only works if someone reads it.
2. Our heading font rewrote the model numbers
Our display font is Estedad, in its FD build («Farsi Digits»). In that build, the ASCII digits 0 to 9 are drawn with Persian shapes. So a blog heading that said RTX 5070 showed RTX ۵۰۷۰, and a CPU name showed i۷-۱۳۶۵۰HX. The same string in body text, twenty pixels away, looked right.
Nothing caught it, because the HTML, the copied text and the JSON-LD all said 5070. Only the pixels were wrong. We measured it in the browser: 4 and ۴ have the same advance width, and none of lnum, locl or ss01 to ss08 changes the rendering. No OpenType feature brings the Latin forms back.
Model numbers are Latin proper nouns, so we cut strings into Latin runs and render each run in the body font:
// may START with a digit: "4GB" is one token, not two
const LATIN_RUN = /[A-Za-z0-9]+(?:[-./+_ ]?[A-Za-z0-9]+)*/g
// a run needs one ASCII letter, so a bare "2026" keeps Persian digits
const HAS_LETTER = /[A-Za-z]/
export function LatinRun({ children }: { children: ReactNode }) {
return <span dir="ltr" className="font-sans">{children}</span>
}
The first version of that regex required a leading letter. It split 4GB into a 4 span and a GB span, and inside an RTL line the bidi algorithm put them back in the other order. A chip on our category page read GB4 the first time we rendered it. Never split one token across two elements.
The font had also been hiding a bug of ours. Quantities built with template strings, like `${ram} گیگ`, produced ASCII digits that only looked Persian. On a slow connection the fallback font renders first, and they would have flashed Latin. Those now go through an explicit toFaDigits(). The rule runs both ways: anything a machine reads, like schema.org prices or the code in a verification email that people retype, stays in Latin digits.
3. One word, three spellings
Many Persian words contain a zero-width non-joiner (U+200C), the «half space». PlayStation is «پلیاستیشن» with one. Many shoppers type it with a full space, «پلی استیشن», and some run it together, «پلیاستیشن».
We ran our real search functions against the catalogue to see which normalization matches what:
- Delete the ZWNJ on both sides: the ZWNJ and fused spellings match, the spaced one misses.
- Turn the ZWNJ into a space and match tokens: the ZWNJ and spaced spellings match, the fused one misses.
No fold that keeps word boundaries covers all three, so token search can't have it. Matching a whole phrase can: delete the ZWNJ and every space, and all three become «پلیاستیشن», at the cost of looser matches. So our token search turns the ZWNJ into a space, and our phrase router squashes everything. Both sides of a comparison always go through the same function.
Two more things. \s in a JavaScript regex does not match U+200C, so word counts differ by spelling. And some platforms strip U+200C from text you paste into them.
4. Toman on the page, Rial in the markup
Iranians quote prices in Toman. Schema.org needs an ISO 4217 code, and the code for Iran's currency is IRR, the Rial. One Toman is ten Rial. So every price has two spellings a factor of ten apart, and Google expects the structured price to match the visible one.
We store and show Toman everywhere, and the ×10 happens in exactly one function:
const offer = {
'@type': 'Offer',
priceCurrency: 'IRR',
price: String(p.price * 10), // stored in Toman; schema.org wants Rial
}
Multiplying by ten is easy. The risk is a second place that prints a price, like a share card or a mobile buy bar, showing one number while the markup says another. So on the product page the buy box, cart line, JSON-LD and share card all come from one resolver over one query result. An end-to-end test parses the JSON-LD and checks the buy box shows the same number divided by ten.
Two related bugs from the same code reached the live site. A component added «تومان» after a formatter that already adds it, and the price history read «تومان تومان». And priceValidUntil was set to today's date, so every offer would have expired the day Google crawled it. A crawl audit caught that on the day we turned indexing on. It is now a date 30 days ahead, and the test asserts it is in the future, which is the property the bug broke.
5. Tehran's midnight is 20:30 UTC
Iran stopped using daylight saving after the summer of 2022, so Tehran is now a fixed UTC+3:30. Our servers run in UTC. Between 20:30 and 24:00 UTC it is already tomorrow in Tehran, and anything keyed on a UTC date is wrong for three and a half hours every night.
A flaky test bit us on it. A helper pinned timestamps to 12:00 UTC, and the code under test grouped price changes by Tehran day. It passed at 19:00 UTC when it was written and failed in CI at 20:37, with nothing changed but the clock. The natural reaction, re-running it, would have made it pass.
We name the zone (Asia/Tehran) wherever we can. For hot paths we do the arithmetic, but only because a test compares it with Intl for every hour of a year:
const TEHRAN_OFFSET_MS = 12_600_000 // +03:30, no DST after 2022
const DAY_MS = 86_400_000
export const startOfTehranDayMs = (now = Date.now()) =>
Math.floor((now + TEHRAN_OFFSET_MS) / DAY_MS) * DAY_MS - TEHRAN_OFFSET_MS
The same boundary came back when we added a data cache. Three queries had «now minus N days» in their where clause. A query that contains the current time is a new cache key every millisecond. Freeze it instead, and a strike-through price that should expire in two minutes stays up until the next write. So a cached loader never reads the clock. It queries from the start of today minus N days, which only changes at Tehran midnight and always includes every row the exact query would return, then makes the exact cut in JavaScript on each request.
This post was drafted with help from an AI assistant working from our commit history, tests and code. The incidents, measurements and code are from our own repository and were checked against that history before publishing.
Top comments (0)