Dayward AI
Week 1 · D2About 4 hours

Streaming Without Jank: Render Storms, Incremental Markdown, and Scroll Anchoring

Models emit dozens to hundreds of deltas per second, and the naive approach re-renders React on every one of them while dragging down the input the user is typing into. Work through render storms, incomplete Markdown, and stolen scroll position so streaming stays smooth even in long sessions.

Today's goals 0/3

Sign in to tick these off and save your progress.

Today's Goals

  1. Explain what causes a render storm, and what per-frame batching and lowered update priority each fix
  2. Handle incomplete Markdown syntax during streaming, and explain why a code block spanning deltas cannot go straight to a parser
  3. Implement scroll anchoring so scrolling up through history does not yank the user back to the bottom

Yesterday's page runs, but only looks good when the text is short and the network is fast. Today we put it under realistic load and fix it one problem at a time. When you finish, scroll back up and tick the three goals off.

Plain-Language Walkthrough

The interpreter who restarts every sentence: what a render storm looks like

Back to the soundproof booth. Imagine an interpreter who works like this: every time a new word arrives, they start the whole sentence over. The speaker says "this", they say "this". The speaker adds "proposal", they say "this proposal". Another word, and they begin again from "this proposal".

The audience would lose their minds. And it gets slower toward the end of every sentence, because there is more to repeat each time.

That is exactly what yesterday's page does. On each TEXT_MESSAGE_CONTENT we run:

TSXTSX
// The D1 approach: one state update per delta
setMessages((prev) =>
  prev.map((m) => (m.id === event.messageId ? { ...m, text: m.text + event.delta } : m))
)

A model emitting 30 to 100 deltas per second runs that code 30 to 100 times per second. Each run puts React through a full cycle: recompute components, diff, commit. But the screen only refreshes 60 times a second — renders beyond that never reach the user's eyes, and are pure waste.

Worse, the cost of each pass grows with message length. By the end of a five-hundred-word reply, every delta reprocesses all five hundred words.

Measure before you tune: the real numbers, and a counterintuitive finding

Do not optimize by feel. Today's lab page ships with a render counter, so measure first.

Measured on the lab with burst mode (174 deltas):

ApproachDeltasState updatesMessage renders
Per-delta (yesterday)174174176
Per-frame batching (today)1745557

About 3.1x, stable across three repeated runs.

But the more valuable lesson came from a mistake while preparing that experiment. The script originally spaced deltas zero milliseconds apart, on the theory that faster means stormier. The result was the opposite: 174 deltas produced only 6 renders.

The reason is automatic batching, introduced in React 18: multiple state updates within one event loop turn collapse into a single render. With zero spacing the whole stream arrives in one or two chunks, the client issues all its updates inside a single turn, and React merges them.

Per-frame batching: pinning renders to the refresh rate

If the screen only refreshes 60 times a second, rendering more often than that is pointless. So: buffer deltas and apply them once per frame.

requestAnimationFrame is precisely the browser's "about to paint" hook.

TypeScriptTypeScript
export function createFrameBuffer<T>(flush: (batch: T[]) => void) {
  let buffer: T[] = []
  let handle: number | null = null
 
  return {
    push(item: T) {
      buffer.push(item)
      // The point: schedule at most once per frame. Later pushes only fill the buffer.
      if (handle === null) {
        handle = requestAnimationFrame(() => {
          handle = null
          const batch = buffer
          buffer = []
          flush(batch)
        })
      }
    },
  }
}

Those 55 state updates over roughly 730ms work out to one every 13ms, the same order as 60fps's 16.7ms. Render frequency no longer tracks the model's output speed; it is pinned to the display's refresh rate.

One detail people miss: a single frame may contain deltas belonging to different messages. Merge them by message id first, then walk the message array once. Otherwise a hundred deltas still means a hundred array traversals and you saved nothing.

Lowering priority: stop streaming from blocking typing

Batching fixes "too many renders", but a separate problem remains: React treats every state update as equally urgent.

So streaming and typing compete. If the user types while a stream is running, their input visibly lags behind their keystrokes, because React may be busy rendering streamed content when the keypress arrives.

useTransition exists to rank updates:

TSXTSX
const [, startTransition] = useTransition()
 
const buffer = createFrameBuffer<Delta>((batch) => {
  // Streaming content is low priority: typing and clicking must be able to cut in
  startTransition(() => appendDeltas(batch))
})

Updates wrapped in startTransition are interruptible: if something more urgent arrives mid-render, React sets the work down, handles the keystroke, and resumes afterwards.

You cannot appreciate this by reading code. Today's lab page includes a dedicated input box — type in it while a stream runs, toggle batching on and off, and the difference in feel is unmistakable.

Incomplete Markdown: the real difficulty in streaming

Model replies frequently contain code blocks. Mid-stream, the text you hold might be:

TextText
Here is some code:
```ts
const a = 1

The fence opened and has not closed. Hand that to an ordinary Markdown parser and it does one of two things: treat the block as unterminated and swallow everything that follows, or refuse the block and render plain text.

Both produce the same outcome: the instant the fence closes, the layout jumps. A paragraph abruptly becomes a code block, everything shifts, and it is jarring.

The fix is to render in-progress syntax as what it will eventually become. This course's renderer marks an unclosed code block with complete: false, and the UI uses that to show an in-progress treatment such as a highlighted left border — while the block is structurally already a code block. When the content finishes, only the border goes away; nothing reflows.

Inline markers work the same way but reach the opposite conclusion:

TypeScriptTypeScript
// An opened-but-unclosed ** marker is treated as plain text
parseInline('this is **not done yet')    // all text, no bold
parseInline('this is **done** though')   // recognized as strong

Why the inversion? Because half-bolded text flickers as each character arrives and the renderer reconsiders, whereas plain text becoming bold jumps exactly once. Fewer jumps wins — that is the criterion, not a rule to memorize.

Two passes: characters first, color second

Syntax highlighting is expensive. Tokenizing a code block on every arriving character is pure waste, and the result is wrong anyway because the code is not finished.

The industry approach is two passes: first, show the code block as plain text immediately for zero perceived latency; second, once the fence closes and the block is complete, highlight it. The user sees characters appear and then gain color, rather than a spinner followed by a finished block.

This is the second use of that complete flag: it is exactly the signal for whether highlighting is worth doing yet.

Scroll anchoring: stop yanking the user back

Should new content scroll the view to the bottom?

The common mistake is to scroll unconditionally:

TSXTSX
// Wrong: forces the view down on every content change
useEffect(() => {
  listRef.current.scrollTop = listRef.current.scrollHeight
}, [messages])

The user scrolls up to reread a sentence and gets dragged back down immediately, unable to read at all. It is a genuine UX failure, and plenty of shipped products have it.

There is one correct rule: whether to follow depends on where the user currently is, not on whether new content arrived.

TypeScriptTypeScript
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
const pinned = distance <= 48 // leave slack; line height and zoom make this imprecise

Follow while pinned to the bottom, stay put once the user scrolls up, and offer a jump-to-bottom button so they can return. One more detail: when following, assign scrollTop directly rather than smooth-scrolling. During streaming you may scroll every frame, and overlapping smooth animations interrupt each other and end up reading as jank.

Virtualization: hand-roll it first, then decide

Once a session reaches several hundred messages, the sheer number of DOM nodes slows the page down even if nothing re-renders. Virtualization renders only what is in the viewport.

The principle is simple: knowing each message's height lets you compute which ones the current scroll position should show, with an empty spacer holding the total height. The difficulty is that chat messages have variable height — you must render to know the height, but virtualization wants the height before deciding whether to render. Production libraries such as @tanstack/react-virtual resolve this by estimating, measuring after render, and correcting.

The recommendation here: hand-roll a naive version to understand the mechanism, then decide whether you need a library. Most chat sessions stay under a few hundred messages, where the earlier optimizations suffice, and virtualization complicates scroll anchoring, jump-to-message, and search highlighting. Do not pay that cost early.

One more thing worth measuring: memory

Render counts are not the only cost. A long session accumulates message objects, parsed Markdown blocks, and DOM nodes, and none of them go away on their own.

Two habits keep this in check. First, do not keep parsed output you can recompute — caching every message's parsed blocks feels like an optimization until you notice it doubles memory for content the user scrolled past an hour ago. Second, watch the detached-node count in the memory profiler after a long session; a number that keeps climbing usually means an event listener or timer still holds a reference to something the UI has already removed.

Neither matters for a ten-message demo. Both matter the first time someone leaves your app open all afternoon.

Source Reading

Hands-On Lab

🧪 D2 lab: a chat stream that stays smooth and does not steal scroll

Code location: labs/frontend-agent-ux-7days/day-02-steady-stream

Acceptance criteria:

  1. The on-page stats show state updates far below the delta count once batching is on
  2. Turning batching off and rerunning raises the render count visibly, so the two can be compared
  3. Typing in the input box during streaming shows no perceptible lag
  4. An unclosed code block renders with code-block styling during streaming and does not reflow when it closes
  5. Scrolling up stops new content from yanking the view down, and a jump-to-bottom button appears
  6. pnpm typecheck && pnpm selftest exits with code 0

Today builds directly on yesterday; copy the three frozen files across unchanged. Run a baseline and write down the numbers before changing anything — an optimization without a baseline cannot be verified.

  1. Reproduce the render storm with burst mode and record the baseline render count
  2. Implement the per-frame delta buffer, measure again, and compare against the baseline
  3. Lower streaming update priority with useTransition, then type during a stream to feel the difference
  4. Hand-write an incremental Markdown renderer that tolerates unclosed fences and unclosed inline markers
  5. Implement scroll anchoring and a jump-to-bottom button, covering the scrolled-up case

Interview Questions

Four questions below, focused on rendering performance under high-frequency updates, edge handling in incremental parsing, and user expectations around scroll behavior. Open each one and read the analysis before the answer points — practicing the derivation beats memorizing bullets. The "common in China / common globally" tags let you filter by target market.

Checklist and Tomorrow

  • Explain what causes a render storm, and what per-frame batching and lowered update priority each fix
  • Handle incomplete Markdown syntax during streaming, and explain why a code block spanning deltas cannot go straight to a parser
  • Implement scroll anchoring so scrolling up through history does not yank the user back to the bottom
  • Explain why "it does not jank locally" does not mean it will not jank in production
  • All 6 lab acceptance criteria pass
  • Answer at least 3 of the 4 interview questions without looking at the answer points

Tomorrow (D3) we make the interface show which tool the agent is calling, and implement human-in-the-loop approval. The first two days were about displaying what the model says; from tomorrow we handle the model doing things — the heart of the trust problem. Whether a user is willing to let an agent act on their behalf depends on whether they can see what it intends to do and stop it. You will also find that the right way to build approval differs from most people's first instinct.

Interview questions

  • A model is emitting 80 deltas per second, your chat page drops frames, and the input box lags. In what order do you diagnose and fix it?模型每秒吐 80 个增量,你的聊天页开始掉帧、输入框也变卡,你按什么顺序排查和优化?
    Common in ChinaCommon overseasDeep dive#react-performance#streaming#profiling

    How to reason about it · think before answering

    1. This tests your diagnostic order, not your list of optimizations. Reciting 'memo, virtualization, debounce' invites the follow-up 'how do you know that is the cause', and the answer runs dry.
    2. Step one is always measure, never change. Record a profile and find which layer the time goes to: React's render and commit, Markdown parsing, or layout. Different bottlenecks need entirely different fixes.
    3. Once you confirm excessive renders, cut at the source with per-frame batching. The screen refreshes 60 times a second, so renders beyond that never reach the user. Buffer deltas and flush once per requestAnimationFrame, and the render ceiling becomes the refresh rate.
    4. Input lag is a **separate problem** that batching does not fix: React treats all updates as equally urgent, so streaming competes with keystrokes for the main thread. Use useTransition to mark streaming updates low-priority and interruptible. Separating these two concerns is most of the signal in this question.
    5. With headroom left, keep going: memo so finished history does not re-render, two-pass rendering to defer syntax highlighting until a code block closes, and virtualization last. Virtualization goes last because it complicates scroll anchoring, jump-to-message, and search.
    6. Expect the follow-up on why it does not reproduce locally: mock data arrives almost instantly, so React's automatic batching collapses updates within one event loop turn and hides the problem. Over a real network deltas arrive across turns and batching cannot help. Performance tests must mimic real arrival pacing.

    分析过程 · 先想清楚再作答

    1. 这题考的是排查顺序,不是优化手段的清单。上来就背「memo、虚拟化、防抖」的人会被追问「你怎么知道是这个原因」,然后就答不下去了。
    2. 第一步永远是量,不是改。打开性能面板录一段,看时间花在哪一层:是 React 的渲染提交,还是 Markdown 解析,还是布局重排。不同的瓶颈解法完全不同,猜错了做的全是无用功。
    3. 确认是渲染次数过多之后,第一刀砍在源头:按帧合并。屏幕每秒只刷新 60 次,超出的渲染画面根本来不及显示,所以把增量攒到 requestAnimationFrame 里每帧统一更新一次,渲染次数的上限就被钉在刷新率上。
    4. 输入卡顿是**另一个独立问题**,不会被按帧合并解决:React 默认认为所有更新同样紧急,流式渲染会和键盘输入抢主线程。这一刀用 useTransition,把流式更新标成低优先级、可中断,让输入插队。能把这两件事分开说,基本就过了。
    5. 还有余量再往下做:memo 让已完成的历史消息不跟着重渲染,两趟渲染把语法高亮推迟到代码块闭合之后,最后才轮到虚拟化。虚拟化要放最后,因为它会让滚动锚定、跳转、搜索全部变复杂,是成本最高的一步。
    6. 可预期的追问是「为什么本地测不出来」——因为本地 mock 数据几乎瞬间到齐,React 的自动批处理会把同一轮事件循环里的多次更新合并掉,问题被藏起来了。真实网络下增量跨事件循环陆续到达,批处理帮不上忙。性能测试必须模拟真实到达节奏。

    Key points

    • Measure first: profile to see whether the cost is rendering, parsing, or layout, since the fixes differ completely.
    • For excessive renders, batch per animation frame so update frequency tracks the refresh rate rather than the model's output speed.
    • Input lag is a separate issue: use useTransition to make streaming updates low-priority and interruptible.
    • Then memo to skip finished history, two-pass rendering to defer highlighting, and virtualization last.
    • It does not reproduce locally because automatic batching collapses instantly-arriving updates; tests must mimic real pacing.

    答题要点

    • 先量后改:用性能面板确认瓶颈在渲染、解析还是布局,不同瓶颈解法完全不同。
    • 渲染次数过多用按帧合并,把更新频率钉在屏幕刷新率上而不是模型吐字速度上。
    • 输入卡顿是独立问题,用 useTransition 把流式更新降为可中断的低优先级。
    • 再往下依次是 memo 跳过历史消息、两趟渲染推迟语法高亮,虚拟化放最后做。
    • 本地测不出来是因为自动批处理把瞬间到齐的更新合并了,测试要模拟真实到达节奏。
  • Your streaming Markdown renderer receives a code fence that has opened but not yet closed. How should it handle that?流式 Markdown 渲染到一半,代码块的围栏只来了一半,你的渲染器应该怎么处理?
    Common in ChinaCommon overseasIntermediate#markdown#streaming#rendering

    How to reason about it · think before answering

    1. The signal here is whether you have actually built streaming rendering. People who have not say 'wait until it closes', which is the worst option for the user.
    2. Name what breaks in the naive approach: hand unclosed text to a normal parser and it either swallows everything after into the code block or refuses the block and renders plain text. Either way, **the moment the fence closes the layout jumps** as a paragraph abruptly becomes a code block.
    3. The right approach is to render in-progress syntax as what it **will eventually become**: detect the unclosed fence, emit a code block anyway, and flag it as incomplete. The UI uses that flag for an in-progress treatment such as a highlighted left border. When content finishes, the border goes away and nothing reflows.
    4. That incomplete flag has a second use: it is exactly the signal for whether to apply syntax highlighting. Highlighting is expensive, and on unfinished code it is wrong anyway, so the industry approach is two passes — plain text immediately, color once the block closes.
    5. Interestingly, inline markers go the **other** way: treat an unclosed bold marker as plain text rather than bolding early, because half-bolded text flickers with every arriving character while plain-to-bold jumps once. The criterion is which choice flickers less, not a fixed rule.
    6. Expect a follow-up about tables and lists: same principle, but tables are safer rendered a full row at a time since column count can still change.

    分析过程 · 先想清楚再作答

    1. 这题的区分度在于你有没有真做过流式渲染。没做过的人会说「等它闭合再渲染」,而这恰恰是体验最差的做法。
    2. 先说清楚朴素做法坏在哪:把未闭合的文本交给普通解析器,它要么把后面所有内容吞进代码块,要么不认这个块当普通文本。无论哪种,**围栏闭合的那一刻画面都会突然重排**——一段文字忽然变成代码块,位置跳动,非常刺眼。
    3. 正确思路是让「正在生成中」的语法以它**最终会变成的样子**渲染:识别出未闭合的围栏,照样产出一个代码块,只是额外标一个未完成的标记。界面用这个标记显示「还在写」的样式,比如左侧一道高亮边。内容写完时只是去掉那道边,结构不变,所以不重排。
    4. 这个未完成标记还有第二个用途:它正好是「该不该上语法高亮」的判据。高亮很贵,而且代码没写完时高亮结果本来就是错的,所以工业做法是两趟——先出纯文本保证零延迟,闭合后再上色。
    5. 有意思的是行内标记的结论**相反**:只开了口的粗体应该当普通文本,不要提前加粗。因为加粗一半的文字会随着每个字到达反复横跳,而普通文本转粗体只跳一次。判据不是规则而是「哪种跳动更少」,能说出这一层说明你是在权衡而不是背结论。
    6. 可预期的追问是「那表格和列表呢」——同理,按「补全后是什么样」渲染,但表格要注意列数可能还会变,通常等整行到齐再渲染那一行更稳。

    Key points

    • Do not wait for the fence to close; that produces a jarring reflow at the moment it does.
    • Detect the unclosed fence, emit a code block anyway, and flag it incomplete so the UI can show an in-progress treatment.
    • Getting the structure right early means closing only removes a style, with no reflow.
    • That flag also decides whether to highlight: two passes, text first, color after the block closes.
    • Inline markers invert the rule: leave unclosed bold as plain text, since early bolding flickers. The criterion is which flickers less.

    答题要点

    • 不能等闭合再渲染,那会让围栏闭合的瞬间发生一次刺眼的重排。
    • 识别未闭合围栏并照样产出代码块,额外标一个未完成标记供界面显示「还在写」的样式。
    • 结构提前正确,闭合时只是去掉样式,所以不重排。
    • 未完成标记同时是「该不该上语法高亮」的判据,两趟渲染:先出字,闭合后上色。
    • 行内标记结论相反,未闭合时当普通文本,因为提前加粗会反复横跳;判据是哪种跳动更少。
  • When should a chat message list auto-scroll to the bottom, and when should it not? State your rule.聊天消息列表什么时候该自动滚到底部,什么时候不该?说出你的判定规则。
    Common in ChinaCommon overseasBasic#scroll-behavior#ux#chat-ui

    How to reason about it · think before answering

    1. An easy question that a surprising number of products get wrong, which is why interviewers like it. The wrong answer is 'scroll down whenever a message arrives', which ignores the user reading back through history.
    2. There is one rule and you should be able to state it in a sentence: **whether to follow depends on where the user currently is, not on whether new content arrived.**
    3. In practice, compute distance from the bottom as scrollHeight minus scrollTop minus clientHeight. Under a threshold counts as pinned and follows; otherwise stay put. Do not use zero as the threshold, since line height, zoom, and subpixel rounding make it imprecise. Leave a few dozen pixels.
    4. When not following you owe the user a way back, normally a jump-to-bottom button, optionally with an unread indicator. Stopping without offering a return path is its own failure.
    5. An easily missed detail: when following, assign scrollTop directly rather than smooth-scrolling. During streaming you may scroll every frame, and overlapping smooth animations interrupt each other and read as jank. Save smooth scrolling for the explicit jump-to-bottom click.
    6. Expect a follow-up about a user parked exactly on the threshold: add hysteresis by using different thresholds for entering and leaving the pinned state.

    分析过程 · 先想清楚再作答

    1. 这是道送分题,但答错的产品非常多,所以面试官爱问。错误答案是「有新消息就滚到底」,一句话就暴露了没考虑用户正在往回看的情况。
    2. 判定规则只有一条,而且要能一句话说出来:**跟不跟随取决于用户当前在不在底部,而不是取决于有没有新内容。**
    3. 落到实现上就是算距底距离:scrollHeight 减 scrollTop 再减 clientHeight。小于一个阈值就算贴底,跟随;否则不动。阈值不要设成 0,行高、缩放、亚像素都会让判断不精确,留个几十像素的余量。
    4. 不跟随的时候必须给用户一个回去的入口,通常是一个「回到底部」按钮,有新消息时还可以带个未读提示。只停不给回路是另一种体验事故。
    5. 一个容易漏的实现细节:跟随时用 scrollTop 直接赋值,不要用平滑滚动。流式期间每帧都可能滚一次,多个平滑滚动动画会互相打断,看起来反而像卡顿。平滑滚动只用在用户主动点「回到底部」那一次。
    6. 可预期的追问是「用户正好停在阈值边界上反复抖动怎么办」——加一点迟滞,比如进入贴底态和离开贴底态用不同的阈值,避免在边界上反复切换。

    Key points

    • The rule: follow based on where the user is, not on whether new content arrived.
    • Compute distance from the bottom with a threshold of a few dozen pixels, never zero.
    • When not following, provide a jump-to-bottom affordance, optionally with an unread badge.
    • Assign scrollTop directly when following; smooth scrolling every frame interrupts itself and reads as jank.
    • Add hysteresis with different enter and leave thresholds to avoid flapping at the boundary.

    答题要点

    • 规则是跟不跟随取决于用户当前在不在底部,不取决于有没有新内容。
    • 算距底距离判断是否贴底,阈值留几十像素余量,不要用 0。
    • 不跟随时必须提供「回到底部」入口,可以带未读提示。
    • 跟随时直接赋值 scrollTop,不要用平滑滚动,否则每帧的动画互相打断会像卡顿。
    • 边界抖动用迟滞解决:进入和离开贴底态使用不同阈值。
  • Your streaming chat never janks locally but users report lag in production. What could explain that?你的流式聊天在本地怎么测都不卡,一上线用户就抱怨界面卡顿。可能是什么原因?
    Common in ChinaCommon overseasDeep dive#react-performance#testing#debugging

    How to reason about it · think before answering

    1. This probes your understanding of React's batching boundaries, and it has a very specific answer. 'Production machines are slower' is not wrong but scores nothing; the interviewer wants the mechanism.
    2. The mechanism is **automatic batching** in React 18 and later: multiple state updates within one event loop turn collapse into a single render.
    3. Against a local mock, the whole stream often arrives in one or two chunks, so the client issues its updates within a single turn and React merges them all. The naive per-delta setState therefore produces no storm locally. Measured while building this course: with zero spacing, 174 deltas produced only 6 renders.
    4. Over a real network the deltas arrive **across event loop turns**, each landing in its own, so batching cannot help and renders track deltas one to one. Same code: 6 renders locally, 174 in production.
    5. Two conclusions follow: the code should batch per frame regardless, and **performance tests must mimic real arrival pacing**. A mock that fires everything at once gives you systematically false green.
    6. Expect a follow-up on what else behaves this way: anything timing-dependent, such as race conditions, ineffective debouncing, and load-order bugs that only appear on slow networks. The common thread is a local environment fast enough to hide the problem.

    分析过程 · 先想清楚再作答

    1. 这题在考你对 React 批处理边界的理解,而且它有一个非常具体的答案。泛泛答「线上机器差、网络慢」不算错但拿不到分,面试官想听的是机制。
    2. 关键机制是 React 18 之后的**自动批处理**:同一个事件循环轮次里的多次状态更新会被合并成一次渲染。
    3. 本地连 mock 服务时,整条流往往在一两个数据块里就到齐了,客户端在同一轮事件循环里连续调用状态更新,React 把它们全并成一次——于是「每个增量一次 setState」这个写法在本地根本不产生风暴。我自己写课程实验时实测过:间隔设成 0 时,174 个增量只渲染了 6 次。
    4. 真实网络下增量是**跨事件循环陆续到达**的,每个增量各自落在不同的轮次里,自动批处理帮不上忙,于是渲染次数就和增量数一比一了。同一份代码,本地 6 次、线上 174 次。
    5. 所以结论有两层:一是这个写法本来就该改成按帧合并,二是**性能测试必须模拟真实的到达节奏**,mock 服务要在增量之间留真实的微小间隔,否则你的测试在系统性地给你假绿。
    6. 可预期的追问是「还有哪些问题有同类特征」——凡是依赖时序的问题都有,比如竞态、防抖失效、以及只在慢网络下暴露的加载顺序问题。共同点是本地环境太快,把问题藏起来了。

    Key points

    • React 18's automatic batching merges state updates that occur within one event loop turn.
    • Local mock data arrives almost instantly, so updates land in the same turn and collapse, hiding the storm.
    • Over a real network deltas arrive across turns, batching cannot help, and renders track deltas one to one.
    • Fix by batching per frame, and add realistic spacing to the mock so tests stop reporting false green.
    • Race conditions, broken debouncing, and slow-network load ordering share this shape: a too-fast local environment hides them.

    答题要点

    • React 18 的自动批处理会合并同一个事件循环轮次里的多次状态更新。
    • 本地 mock 数据几乎瞬间到齐,更新落在同一轮里被全部合并,风暴不会出现。
    • 真实网络下增量跨事件循环陆续到达,批处理失效,渲染次数与增量数一比一。
    • 解法是按帧合并,同时让 mock 在增量之间留真实的微小间隔,避免测试给出假绿。
    • 同类特征的问题还有竞态、防抖失效、慢网络下的加载顺序,都是被过快的本地环境藏起来的。

Comments