feat(runtime): opt-out for per-prop componentShouldUpdate firing (4.42+)
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
componentShouldUpdateinvocations 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.
Source: stenciljs/core