Skip to content

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

Description

@clholgat

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions