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
- 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.
- Trigger an edit that calls
vm.onChange(next) on the leaf, which synchronously calls this.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 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.
Title
scheduleRender()sometimes drops the next render pass after a state update that follows closely on a scroll/layout eventSummary
StatefulComponent.setState()(valdi_core/src/Component.ts) correctly updatesthis.stateand callsthis.scheduleRender(), but in the web target, ascheduleRender()call occasionally never results in a correspondingonRender()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 subsequentsetState()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 aPatternStepInputcomponent 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 invaldi_core, outside the app repo.Environment
npm run dev)valdi_core— pinned viaarchive_overridetoSnapchat/Valdi@8776dbd68e369efb168deb7688f10dddde8cb121read_console_messageslog captureScope: 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
ScrollViewHandleradapter) or a bug in the cross-platformvaldi_corerender 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
StatefulComponentsubtree where a leaf component (PatternStepInput) is driven purely by props (viewModel.value) from an ancestor (PatternKnitBlock), several levels up, inside a scrollable container.vm.onChange(next)on the leaf, which synchronously callsthis.setState({ [field]: rounded })on the ancestor.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 viamergePartial, 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.logat the top of the leaf'sonRender(), and at the top of the ancestor's state-changing method), tapping "+" twice in a row on the same field:onRender()fired?7.75 → 8, real delta8(correct)8 → 8.25, real delta (confirmed later)8(stale)8.25 → 8.25(a no-op, since it computed from the stale displayed8rather than the true8.25)8The 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'sendComponent()/ whatever consumesscheduleRender()requests) to find the exact swallowed code path, since that'svaldi_coreinternals 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'sPatternStepInput.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 overviewModel.valueuntilonViewModelUpdate()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 asetState()-triggered render reliably reaches its descendants soon after a scroll/layout event.Ask
scheduleRender()/the render queue has any known interaction with scroll/layout passes that could cause a render request to be dropped rather than deferred.