no-ui-flash

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Never flash the wrong UI

切勿显示错误的UI闪烁

Client-rendered apps have a window between first paint and the moment client-side data resolves. The classic bug is filling that window with a placeholder that bets on one outcome — and losing the bet:
  • An app-shell skeleton (sidebar, nav, content cards) shown to a signed-out visitor who is about to be bounced to login. They're shown an app they'll never reach, then a jarring swap.
  • A results-grid skeleton on a search/list page that resolves to "no results found" — the skeleton promised content that doesn't exist.
  • A light-theme first paint that snaps to dark once a preference loads.
  • A skeleton for a route that turns out to be a 404.
The rule: render the placeholder for the state you have verified, not the state you hope for. When you can't verify, render a placeholder that's correct in every outcome (neutral, layout-stable) — not the happy path's. Getting there is three layers; each makes the next one's job smaller.
客户端渲染的应用在首次绘制完成到客户端数据解析完成之间存在一个时间窗口。典型的问题是用一个预设单一结果的占位符填充这个窗口,但最终结果却与预设不符:
  • 向即将被跳转至登录页的未登录访客显示应用外壳骨架(侧边栏、导航栏、内容卡片)。他们会看到一个永远无法访问的应用界面,随后发生突兀的切换。
  • 搜索/列表页上的结果网格骨架最终显示为“无结果”——骨架承诺了不存在的内容。
  • 首次绘制为浅色主题,在偏好设置加载完成后突然切换为深色。
  • 某路由的骨架最终变为404页面
规则:渲染已确认状态对应的占位符,而非你期望的状态对应的占位符。当无法确认状态时,渲染一个在所有结果下都正确的占位符(中立、布局稳定)——而非仅针对理想情况。要实现这一点分为三层,每一层都能减少下一层的工作量。

Layer 1 — resolve the state at the edge, before the document is served

第一层——在文档发送前,于边缘节点解析状态

The server/edge that serves the SPA's HTML usually already holds what the client will spend a round trip discovering. Use it there:
  • Auth: most session schemes verify with no upstream round trip — sealed/signed cookies verify with local crypto, JWTs against a cached JWKS. Gate document requests: signed out →
    302 /login?returnTo=<path>
    before any app HTML exists. The wrong shell can't flash if it's never served. Anyone who receives the SPA at a gated path is now known to be signed in — which makes the client's skeleton honest again.
  • Data-shaped states: if the edge can cheaply answer "empty vs. populated" (a count, a KV flag, a cookie recording last-known state), it can serve the right variant — empty-state HTML, the populated shell, a redirect to onboarding — instead of a one-skeleton-fits-all document.
  • Preferences (theme, locale, density): read the preference cookie at the edge and serve the correct variant in the initial HTML. A class on
    <html>
    beats a client-side flip.
Edge cases that bite (described for auth, but they generalize to any cookie-carried state):
  • Gate document navigations only (GET/HEAD with
    sec-fetch-dest: document
    , falling back to
    Accept: text/html
    ). API routes, module requests, and health probes answer for themselves.
  • Clear invalid state carriers, don't just route around them. A cookie that fails validation is worse than none — anything keyed on its presence keeps misbehaving until it's gone. Expire it on the response.
  • Persist anything the check rotated. If verification refreshed a token, the new value MUST reach the browser on this response — refresh tokens are typically single-use, and dropping the rotation silently breaks the session later. Applies on the serve path, not just redirects.
  • Failures inside the edge check collapse to the safe state (signed out, default theme), never to a 500.
提供SPA HTML文件的服务器/边缘节点通常已经掌握了客户端需要通过一次往返请求才能获取的信息。直接在节点上利用这些信息:
  • 身份验证:大多数会话方案无需上游往返请求即可验证——密封/签名Cookie通过本地加密验证,JWT通过缓存的JWKS验证。对文档请求进行拦截:未登录 → 在发送任何应用HTML前返回
    302 /login?returnTo=<path>
    。如果错误的外壳从未被发送,就不会出现闪烁。任何在受限路径下收到SPA的用户都已确认为已登录状态——这让客户端的骨架再次变得真实可信。
  • 数据形态状态:如果边缘节点可以低成本判断“为空 vs 已填充”(计数、KV标志、记录上次已知状态的Cookie),它可以发送正确的变体——空状态HTML、已填充的外壳、重定向至引导页面——而非通用的单一骨架文档。
  • 偏好设置(主题、语言、密度):在边缘节点读取偏好Cookie,并在初始HTML中提供正确的变体。在
    <html>
    上添加类名比客户端切换效果更好。
需要注意的边缘情况(以身份验证为例,但可推广到任何携带状态的Cookie):
  • 仅拦截文档导航请求(GET/HEAD请求且
    sec-fetch-dest: document
    , fallback为
    Accept: text/html
    )。API路由、模块请求和健康检查请求自行处理。
  • 清除无效状态载体,而非仅绕过路由。验证失败的Cookie比没有Cookie更糟——任何基于其存在性的逻辑都会持续异常,直到Cookie被清除。在响应中设置其过期时间。
  • 保留任何检查过程中更新的内容。如果验证过程刷新了令牌,新值必须随本次响应发送至浏览器——刷新令牌通常是一次性的,若静默丢弃更新会导致后续会话失效。这适用于服务路径,而非仅重定向。
  • 边缘节点检查过程中的失败应回退到安全状态(未登录、默认主题),绝不能返回500错误。

Layer 2 — a hint cookie for instant correct paints

第二层——使用提示Cookie实现即时正确绘制

Whatever the client normally learns from its first probe (
/me
, first search, preferences fetch), snapshot it into a non-HttpOnly cookie so the next load paints correctly without waiting:
  • The client writes it whenever the server confirms the state, and reads it on the next load to seed an optimistic version while the probe is in flight. (Auth: identity display data. Search/list: "this account has data" or last result count. Theme: the resolved preference.)
  • It is a hint, never an authority. Real authorization and real data still come from the server; a stale or forged hint can only change which placeholder briefly renders. Keep the payload to display data the user already knows; schema-validate on read and treat anything malformed as absent.
  • The resolved probe always wins — reconcile the moment it lands.
  • Clear it everywhere the underlying state dies (logout, account wipe, preference reset) — server-side on those responses, and client-side when a probe contradicts it. A hint that outlives its truth paints the wrong UI confidently.
  • In an SSR/hydrating app, read the cookie after mount (effect/state), not during the first render — the first client render must match the server HTML. The flip costs one frame, not a round trip.
With layers 1+2, the client's "loading" placeholder is only reachable in states where it's genuinely correct (e.g. a verified user's first visit on a new browser) — so the optimistic skeleton is finally honest.
客户端通常从首次请求(
/me
、首次搜索、偏好设置获取)中获取的任何信息,都可以快照到非HttpOnly Cookie中,这样下一次加载时无需等待即可正确绘制:
  • 客户端在服务器确认状态时写入该Cookie,并在下次加载时读取它,以便在请求进行时生成乐观版本的状态。(身份验证:身份显示数据。搜索/列表:“该账户有数据”或上次结果计数。主题:已解析的偏好设置。)
  • 这只是一个提示,而非权威来源。真正的授权和数据仍来自服务器;过期或伪造的提示只能短暂改变占位符的显示。仅保留用户已知的显示数据;读取时进行 schema 验证,将任何格式错误的内容视为不存在。
  • 已解析的请求结果始终优先——请求完成后立即进行协调。
  • 在底层状态失效的所有场景中清除该Cookie(登出、账户清空、偏好重置)——在这些操作的服务器响应中清除,以及当请求结果与提示矛盾时在客户端清除。如果提示在其对应的状态失效后仍存在,会自信地显示错误的UI。
  • 在SSR/ hydration应用中,挂载后(effect/state)读取Cookie,而非首次渲染时——首次客户端渲染必须与服务器HTML匹配。切换仅需一帧,而非一次往返请求。
通过第一层+第二层,客户端的“加载中”占位符仅在真正正确的状态下才会出现(例如已验证用户在新浏览器上的首次访问)——因此乐观骨架终于变得真实可信。

Layer 3 — the client fallback becomes a safety net

第三层——客户端 fallback 成为安全网

The in-app state gate no longer handles fresh loads; it handles mid-session change (logout in another tab, expiry, data deleted elsewhere):
  • On a state that invalidates the current UI, transition via a full navigation and render something neutral (a blank themed screen) for the moment that takes — never the placeholder of the UI they just lost.
  • Unknown routes need a real 404 page, not the shell or skeleton. If there's no
    notFound
    route, the fallback is probably the thing accidentally rendering a skeleton for garbage URLs — check.
应用内状态拦截不再处理首次加载,而是处理会话中途变更(在其他标签页登出、令牌过期、数据在别处被删除):
  • 当状态失效导致当前UI无效时,通过完整导航进行过渡,并在过渡期间渲染中立内容(空白主题屏幕)——绝不要渲染用户刚刚失去的UI对应的占位符。
  • 未知路由需要真实的404页面,而非外壳或骨架。如果没有
    notFound
    路由,fallback很可能会意外地为无效URL渲染骨架——请检查。

Redirect-back (returnTo) for gates that bounce

用于跳转拦截的返回重定向(returnTo)

If the edge redirects (login, onboarding), deep links must survive the detour. Resist adding a second cookie for it — if the flow is OAuth-shaped, the
state
parameter already round-trips through the provider verbatim and is already authenticated by the CSRF check (state pinned in a cookie, compared timing-safe at the callback). Ride along:
state = base64url(JSON { nonce, returnTo })
. One value, one cookie, and an attacker can't swap the destination without breaking the comparison.
Wherever a returnTo enters (gate query, login page, callback's decoded state), validate it as a same-origin relative path: starts with
/
, not
//
(protocol-relative is an absolute URL in disguise), and not an API path. Anything else falls back to
/
. Decoding must be total — providers send callbacks with state you never minted; junk reads as "no returnTo", never a throw.
如果边缘节点进行重定向(登录、引导),深度链接必须在跳转后保留。不要添加第二个Cookie来存储它——如果流程是OAuth类型,
state
参数已经通过服务商完整往返,并且已经通过CSRF检查进行了身份验证(state固定在Cookie中,在回调时进行时序安全的比较)。可以这样处理:
state = base64url(JSON { nonce, returnTo })
。一个值,一个Cookie,攻击者无法在不破坏比较的情况下替换目标地址。
无论returnTo从何处传入(拦截查询参数、登录页、回调解码后的state),都要验证它是同源相对路径:以
/
开头,不是
//
(协议相对路径实际上是绝对URL的伪装),也不是API路径。其他情况都回退到
/
。解码必须是完整的——服务商会发送你从未生成的state;无效内容应视为“无returnTo”,绝不能抛出错误。

Testing the loading window

测试加载窗口

The bug lives in a timing window, so the test must hold the window open:
  • Intercept the probe and delay it (e.g. Playwright
    page.route
    on
    /me
    or the search endpoint with a sleep). Assert what is painted during the delay: for the bounced visitor, the destination page with zero skeleton elements; for the hinted user, the real UI — plus a flag proving the probe had not resolved when it painted.
  • Drive the full redirect round trip over the wire: gated deep link → redirect carrying returnTo → provider → callback → lands on the deep link. Then the forged version: an off-origin returnTo completes the flow but lands on
    /
    .
  • Assert cookie hygiene as Set-Cookie headers: invalid state carrier → cleared (Max-Age=0) alongside its hint; the death event (logout etc.) → both gone.
  • Drive the rotation path without waiting out expiry: if the carrier is a sealed cookie, unseal it with the same library the server uses, corrupt the inner token's signature, reseal. The edge sees invalid-but-refreshable: assert the page is served, the rotated value is set on the response, and the spent one is refused on replay.
这类bug存在于时间窗口中,因此测试必须保持窗口打开:
  • 拦截请求并延迟响应(例如使用Playwright的
    page.route
    拦截
    /me
    或搜索端点并添加延迟)。断言延迟期间的绘制内容:对于被跳转的访客,目标页面不应包含任何骨架元素;对于有提示的用户,应显示真实UI——同时要有标志证明绘制时请求尚未解析完成。
  • 在网络上完成完整的重定向往返流程:受限深度链接 → 携带returnTo的重定向 → 服务商 → 回调 → 跳转到深度链接。然后测试伪造版本:跨域returnTo完成流程后跳转到
    /
  • 验证Set-Cookie头中的Cookie卫生情况:无效状态载体 → 被清除(Max-Age=0)及其提示Cookie;状态失效事件(登出等)→ 两者都被清除。
  • 在不等待过期的情况下测试更新路径:如果载体是密封Cookie,使用服务器相同的库解密,破坏内部令牌的签名,重新密封。边缘节点会将其视为无效但可刷新:断言页面被正常服务,响应中设置了更新后的值,并且已过期的值在重放时被拒绝。