Snapchat / Snapchat/Valdi

scheduleRender() sometimes drops the next render pass after a state update near a scroll/layout event

Open
#118 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
C++
Stars
16.4k
Forks
537
Avg merge
22d 5h
Merged PRs (30d)
1

Description

Title

scheduleRender() sometimes drops the next render pass after a state update that follows closely on a scroll/layout event

Summary

StatefulComponent.setState() (valdi_core/src/Component.ts) correctly updates this.state and calls this.scheduleRender(), but in the web target, a scheduleRender() call occasionally never results in a corresponding onRender() invocation. The component's underlying state (and anything derived from it, e.g. localStorage writes made in the same call) is correct, but the rendered output is not — it continues showing whatever was last painted. A subsequent setState() call on the same component computes from the stale rendered props/closures rather than the true current state, compounding the problem.

We hit this in a downstream app (pattern_website) in a PatternStepInput component nested a few levels under a page that also drives a scroll-view (ScrollViewHandler / Subscreen). We were able to reproduce it reliably and trace it to the renderer, but stopped short of root-causing the scheduler/renderer internals themselves since that code lives in valdi_core, outside the app repo.

Environment

  • Target: web (webpack dev server, npm run dev)
  • valdi_core — pinned via archive_override to Snapchat/Valdi@8776dbd68e369efb168deb7688f10dddde8cb121
  • Reproduced in Chrome (desktop), both in a live dev session and via read_console_messages log capture

Scope: confirmed web-only, native untested

Everything below was reproduced and instrumented exclusively on the web target. We have not tested iOS or Android, so we can't say whether this is a web-specific integration issue (e.g. something in the browser-DOM scroll path or the web ScrollViewHandler adapter) or a bug in the cross-platform valdi_core render scheduler itself, which native targets would share. If the scheduler is the culprit, native platforms likely need their own scroll/layout-adjacent trigger to surface it, so an absence of reports from native doesn't rule it out.

Repro steps

  1. Render a StatefulComponent subtree where a leaf component (PatternStepInput) is driven purely by props (viewModel.value) from an ancestor (PatternKnitBlock), several levels up, inside a scrollable container.
  2. Trigger an edit that calls vm.onChange(next) on the leaf, which synchronously calls this.setState({ [field]: rounded }) on the ancestor.
  3. Immediately after this first edit, the page reflows (instruction text below recalculates length) and the scroll view auto-adjusts position (~200px, scroll-into-view-style).
  4. Trigger a second edit on the same leaf component (e.g. tap the "+" step button again) shortly after.
  5. Observe: the second edit's computed value is derived from the value shown at step 1 (pre-edit), not the value actually committed at step 1 — i.e. the leaf's onRender() was never called between the two edits, even though this.state on the ancestor changed correctly both times.

Expected behavior

Every setState() call that produces an actual value delta (verified via mergePartial, i.e. not a no-op) should result in a render pass that reaches every descendant component whose props actually changed, with no dropped passes — regardless of any concurrent scroll/layout activity.

Actual behavior

Confirmed via temporary instrumentation (console.log at the top of the leaf's onRender(), and at the top of the ancestor's state-changing method), tapping "+" twice in a row on the same field:

Tap Ancestor state change Leaf onRender() fired? Displayed value
1 (from a fresh value) 7.75 → 8, real delta Yes — full render, correct props 8 (correct)
2 8 → 8.25, real delta (confirmed later) No — zero render calls logged still 8 (stale)
3 8.25 → 8.25 (a no-op, since it computed from the stale displayed 8 rather than the true 8.25) No (nothing to render) still 8

The underlying state was correct at every step (confirmed by reading persisted storage directly after each tap). Only the render pass silently stopped propagating after the first edit. The gap between tap 2 and tap 3 was several seconds — long enough to rule out same-frame batching/coalescing as an explanation.

Suspected trigger

The only difference between the first edit (which rendered correctly) and the second (which didn't) was that the first edit was immediately followed by an auto-scroll of ~200px (the page's own scroll view repositioning as content below the edited field changed height). No such scroll occurred around the second or third edits. This strongly suggests a scroll/layout-adjacent code path in the renderer or scroll-view integration is leaving some piece of render-scheduling state (a dirty flag, a debounce timer, a "render in flight" guard, etc.) in a bad state, such that the next scheduleRender() call is swallowed.

We did not trace further into valdi_core's renderer/scheduler (e.g. Renderer.ts's endComponent() / whatever consumes scheduleRender() requests) to find the exact swallowed code path, since that's valdi_core internals rather than app code — flagging it here for the framework owners to dig into with full context on the scheduler's design.

Relevant framework code (for reference, not owned by the app repo)

  • valdi_core/src/Component.ts, StatefulComponent.setState() (~L194–212): computes mergePartial(state, this.state), and calls this.scheduleRender()this.renderer.renderComponent(this, undefined) only if there's a real delta.
  • valdi_core/src/Renderer.ts, endComponent() (~L2024 on): where viewModel/onViewModelUpdate and re-render decisions are handled — this is where we'd start looking for where a scheduled render could be dropped.

Workaround applied downstream

In pattern_website's PatternStepInput.tsx, we no longer rely on the parent's re-render reaching the component to reflect its own just-committed value. The component now tracks its last-committed value in local state and prefers it over viewModel.value until onViewModelUpdate() confirms a fresh prop has actually arrived. This makes the component resilient to the dropped render, but doesn't address the underlying scheduling bug — a similar failure mode could affect any other component that assumes a setState()-triggered render reliably reaches its descendants soon after a scroll/layout event.

Ask

  • Confirm whether scheduleRender()/the render queue has any known interaction with scroll/layout passes that could cause a render request to be dropped rather than deferred.
  • If reproducible on your end, a minimal repro (component tree + scroll trigger) can be provided.
  • Given this is unconfirmed on native, we'd appreciate the framework team (or us, with guidance) trying the same edit-then-edit-again-near-a-scroll repro on iOS/Android to establish whether this is web-specific or a shared-scheduler issue.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with valdi_core/src/Component.ts around StatefulComponent.setState(), then inspect valdi_core/src/Renderer.ts around endComponent() and the render queue. Run the web reproduction with npm run dev, performing two edits around the scroll/layout adjustment. Done means every real state delta reaches changed descendants without a dropped render, with the web-only behavior and native status documented.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
frontend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.