scheduleRender() sometimes drops the next render pass after a state update near a scroll/layout event
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 viaarchive_overridetoSnapchat/Valdi@8776dbd68e369efb168deb7688f10dddde8cb121- Reproduced in Chrome (desktop), both in a live dev session and via
read_console_messageslog 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
- Render a
StatefulComponentsubtree where a leaf component (PatternStepInput) is driven purely by props (viewModel.value) from an ancestor (PatternKnitBlock), several levels up, inside a scrollable container. - Trigger an edit that calls
vm.onChange(next)on the leaf, which synchronously callsthis.setState({ [field]: rounded })on the ancestor. - 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).
- Trigger a second edit on the same leaf component (e.g. tap the "+" step button again) shortly after.
- 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 thoughthis.stateon 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): computesmergePartial(state, this.state), and callsthis.scheduleRender()→this.renderer.renderComponent(this, undefined)only if there's a real delta.valdi_core/src/Renderer.ts,endComponent()(~L2024 on): whereviewModel/onViewModelUpdateand 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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