Skip to content

fix(web_core): derive the active tab index instead of writing it in willUpdate - #2647

Draft
josemontespg wants to merge 1 commit into
web-core-unbound-update-gatefrom
web-core-tabs-controller-less-lifecycle
Draft

josemontespg wants to merge 1 commit into
web-core-unbound-update-gatefrom
web-core-tabs-controller-less-lifecycle

Conversation

@josemontespg

@josemontespg josemontespg commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Updates the Tabs component to derive its active tab index dynamically during render() rather than mutating @state() in willUpdate(), and removes defensive optional chaining on this.controller across the basic catalog.

Problem

The Tabs component previously used willUpdate() to clamp this.activeIndex whenever the number of available tabs changed:

protected override willUpdate() {
  const props = this.controller.props;
  if (this.activeIndex >= props.tabs.length) {
    this.activeIndex = 0;
  }
}

This pattern caused several issues:

  1. Unnecessary extra render cycles: Modifying an internal @state() property inside willUpdate() requests an additional update pass from Lit.
  2. Destructive state overwrite: Overwriting this.activeIndex permanently erased the user's selection. If a tabs array temporarily shrunk (such as during dynamic data filtering) and later restored its items, the user's tab selection was lost.
  3. Crash before context attachment: Because willUpdate() ran before this.controller was instantiated, reading this.controller.props threw a TypeError if <a2ui-tabs> was rendered before being bound to a context.
  4. Misleading optional chaining: Multiple places in the basic catalog used this.controller?.props. Because this.controller is guaranteed once an element updates, optional chaining masked programming errors and allowed components to silently render blank UI instead of surfacing an issue.

Solution

  • Derive active tab index in render():
    Remove willUpdate() entirely from Tabs.ts. In render(), compute the clamped index as a local derived value:
    const activeIndex = this.activeIndex < props.tabs.length ? this.activeIndex : 0;
    This eliminates the extra render pass, preserves the stored user selection when tab counts change, and ensures the controller is accessed only when guaranteed to exist.
  • Remove redundant optional chaining:
    Replace this.controller?.props with this.controller.props in BasicCatalogA2uiLitElement and ChoicePicker. A missing controller now surfaces immediately at the access point rather than silently producing empty markup.

Verification

  • Added tests in Tabs.test.ts:
    • Verifies that <a2ui-tabs> can mount without a context without throwing an error.
    • Verifies that when the tabs list shrinks below this.activeIndex, the rendered content falls back to the first tab while preserving the user's selection in this.activeIndex.
  • yarn --cwd renderers/web_core test passes (545 tests).

Pre-launch Checklist

@github-project-automation github-project-automation Bot moved this to Todo in A2UI Sep 14, 2026
@josemontespg
josemontespg added this pull request to stack #2649 September 14, 2026 19:14

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the update lifecycle of A2UI Lit elements by replacing the update override with a shouldUpdate override to gate the update cycle on the element being bound to a controller. This ensures that the controller is guaranteed to be present during rendering and updates, allowing the removal of optional chaining (?.) on this.controller across various components and documentation. Additionally, the Tabs component was updated to clamp the active index during render rather than mutating it in willUpdate. New tests were added to verify element updates without a context and to ensure correct behavior when the tabs array shrinks. There are no review comments, so I have no feedback to provide.

Comment thread renderers/lit/README.md
Comment thread renderers/web_core/src/v0_9/basic_catalog/catalog.test.ts Outdated
Comment thread renderers/web_core/src/v0_9/basic_catalog/catalog.test.ts Outdated
Comment thread renderers/web_core/src/v0_9/catalog/a2ui-lit-element.ts
Comment thread renderers/web_core/CHANGELOG.md Outdated
@josemontespg
josemontespg removed this pull request from stack #2649 September 14, 2026 19:58
@josemontespg
josemontespg changed the base branch from web-core-writable-controller to web-core-unbound-update-gate September 14, 2026 19:58
@josemontespg
josemontespg force-pushed the web-core-tabs-controller-less-lifecycle branch from 6d72160 to 4ef3bdc Compare September 14, 2026 19:59
@josemontespg josemontespg changed the title fix(web_core): keep the controller-less lifecycle safe for unbound elements fix(web_core): derive the active tab index instead of writing it in willUpdate Sep 14, 2026
@josemontespg
josemontespg added this pull request to stack #2654 September 14, 2026 20:02
@josemontespg
josemontespg removed this pull request from stack #2654 September 14, 2026 20:24
…illUpdate

Tabs.willUpdate read this.controller.props to reset activeIndex when the
tabs array shrinks. Resetting a @State() field during the update cycle is
a side effect standing in for a derivation: it schedules a second pass and
silently discards a selection the user may still hold if the array grows
back. It also ran before the base class binds the controller, so mounting
<a2ui-tabs> before a context was attached threw.

Clamp in render() instead, where the controller is guaranteed and where
the value is recomputed per render rather than stored.

With the controller guaranteed after binding, the remaining
this.controller?. reads in the basic catalog no longer describe a state
that can occur. Removing them means a missing controller fails at the read
rather than rendering an empty component with no explanation.
@josemontespg
josemontespg force-pushed the web-core-tabs-controller-less-lifecycle branch from 4ef3bdc to f69b9e5 Compare September 14, 2026 20:27
@josemontespg
josemontespg added this pull request to stack #2655 September 14, 2026 20:28

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant