#6759·core

feat(runtime): opt-out for per-prop componentShouldUpdate firing (4.42+)

Author: maisasbCreated Jun 24, 2026Updated Jul 20, 2026
LabelsStencil v5

Context

PR #6587 (v4.42) changed componentShouldUpdate to fire once per changed @Prop instead of once per render batch. The PR closed #2061 and treats the previous batched behavior as a bug.

While per-prop firing matches the documented contract of componentShouldUpdate, it has a noticeable performance impact on component libraries whose components carry many @Props. Every parent re-render now multiplies the lifecycle cost by N (number of changed props).

This isn't a request to revert #6587. It's a request to expose an opt-out for codebases that would prefer the previous batched semantics.

Impact

Consider a form-input component with 24 @Props, used 30 times on a single page:

  • Before 4.42: 30 × 1 = 30 componentShouldUpdate invocations per parent re-render
  • After 4.42: 30 × 24 = 720 invocations per parent re-render

For most apps this is invisible. For dense-form pages (settings panels, checkout, multi-field surveys), it surfaces as TBT/INP regressions after upgrading to ≥4.42.

Repro shape

typescript
@Component({ tag: 'my-input', shadow: true })
export class MyInput {
  @Prop() value: string
  @Prop() label: string
  @Prop() placeholder: string
  // ... 20 more @Prop
  componentShouldUpdate() {
    console.count('shouldUpdate')
    return true
  }
  render() { return <input /> }
}

Render 50 instances, then assign el.value = next once. Pre-4.42: 1 increment. Post-4.42: 24 increments — one per @Prop that the runtime reconciles.

Proposed solutions (any one would help)

1. Component-level opt-in flag

typescript
@Component({
  tag: 'my-input',
  batchedShouldUpdate: true   // restore pre-4.42 batched firing
})

Scoped, opt-in, no behavior change for existing users.

2. Global default componentShouldUpdate hook

stencil.config.ts:

typescript
export const config: Config = {
  defaults: {
    componentShouldUpdate(newVal, oldVal, propName) {
      // applied to every component that does not define its own hook
      return /* ... */
    }
  }
}

Solves the "N components" problem at config level. A library of 60 components becomes one config block instead of 60 method definitions.

3. Mixin support (#6584)

If v5 lands Mixin support, a WithPerfBail mixin would cover this:

typescript
@Component({ tag: 'my-input', shadow: true })
export class MyInput extends WithPerfBail(BaseInput) { ... }

Current workaround

Adding a default componentShouldUpdate to each high-prop component manually:

  • Bail when Object.is(newVal, oldVal) (reference equality on stable callbacks/values)
  • Bail on known render-irrelevant prop names (data-testid, analytics/tracking keys)
  • Otherwise return true

Mechanical (~10 lines per component) but spreads across N files and is easy to miss for new components. A build-time codemod over the compiled output is feasible but fragile against future runtime changes.

Why this matters

Stencil libraries are exactly the case where this scales worst — sites with 30–100 @Components consumed by Angular/React/Vue apps. The cost is (component count) × (prop count) × (parent re-render frequency). For form-heavy SaaS, all three multipliers are high.

A batchedShouldUpdate: true flag (option 1) would be enough for affected consumers to opt back in, with zero impact on anyone relying on the new per-prop contract.