Skip to content

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

Description

@maisasb

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

@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

@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:

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:

@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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Stencil v5This is slated for Stencil v5 (Release date TBD)
    No fields configured for Feature.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions