Skip to content

Refactor Toggle.rerender() to Preserve Instance #318

@palcarazm

Description

@palcarazm

Short Description of the Feature

Refactor the rerender() method in Toggle class (BootstrapToggle.ts) to reinitialize the component internally without destroying the current instance. Currently, rerender() calls this.destroy() followed by new Toggle(...), which creates a completely new instance and breaks reference continuity. The new implementation will reuse the existing instance, restoring properties, unbinding listeners, rebuilding DOM, and reinitializing state.

Current behavior:

rerender() {
    this.destroy();
    const _ = new Toggle(this.element, this.userOptions);
}

Target behavior:

rerender() {
    this.restoreInputProperties();
    this.unbindEventListeners();
    this.domBuilder.destroy();
    this.options = OptionResolver.resolve(this.element, this.userOptions);
    this.stateReducer = new StateReducer(this.element, this.options.tristate);
    this.domBuilder = new DOMBuilder(
        this.element,
        this.options,
        this.stateReducer.get()
    );
    this.bindEventListeners();
    this.interceptInputProperties();
}

Expected Benefits

  • Instance continuity: this.element.bsToggle continues to reference the same instance (no reassignment needed)
  • Memory efficiency: Avoids creating new instances and discarding old ones
  • Event listener integrity: External code holding references to the original instance continues to work
  • Prerequisite for lifecycle migration: This refactor enables Integrate BootstrapToggle with component-lifecycle Library #317 by providing a rerender() that works without destroy() + recreation
  • Simpler mental model: Rerender now means "rebuild internal structures" not "kill and respawn"

Acceptance Criteria

  • rerender() no longer calls this.destroy() or creates new Toggle instances
  • rerender() follows the exact sequence defined above
  • this.element.bsToggle remains unchanged (still references the same this instance)
  • No try/catch blocks added for error handling (errors propagate naturally)
  • No suppressExternalSync guard added (keeps current behavior)
  • Return type remains void (no chaining support)
  • Method works regardless of current lifecycle state (lifecycle not yet implemented)
  • Existing destroy() method remains unchanged and functional
  • No new events emitted (no toggle:rerendered)
  • Method is NOT async (synchronous execution)
  • All existing tests pass; no regressions introduced

Documentation

Sequence Diagram of New rerender() Flow

sequenceDiagram
    participant T as Toggle Instance
    participant E as HTMLInputElement
    participant L as Event Listeners
    participant D as DOMBuilder
    participant O as OptionResolver
    participant S as StateReducer

    Note over T: rerender() called
    
    T->>E: restoreInputProperties()
    Note over E: Restores original getters/setters
    
    T->>L: unbindEventListeners()
    Note over L: Removes pointer/keyboard/label/form listeners
    
    T->>D: domBuilder.destroy()
    Note over D: Removes toggle DOM<br/>Restores original checkbox<br/>Disconnects ResizeObserver
    
    T->>O: options = OptionResolver.resolve(element, userOptions)
    Note over O: Re-reads from element attributes
    
    T->>S: stateReducer = new StateReducer(element, tristate)
    Note over S: Creates fresh state (resets to initial)
    
    T->>D: domBuilder = new DOMBuilder(options, state)
    Note over D: Builds new toggle DOM structure
    
    T->>L: bindEventListeners()
    Note over L: Re-attaches all event listeners
    
    T->>E: interceptInputProperties()
    Note over E: Re-intercepts getters/setters
    
    Note over T: Instance rebuilt (same object reference)
Loading

Key Differences from Current Implementation

Aspect Current New
Instance identity Changes (new object) Preserved (same object)
element.bsToggle reference Reassigned to new instance Unchanged (still references same instance)
External references Break (point to destroyed instance) Remain valid
Memory allocation New instance + GC of old No new instance
State initialization From constructor options From re-resolved options (same result)
Error handling Errors in new constructor propagate Errors propagate (no catch)

Why This Order Matters

  1. Restore properties first: Ensures element returns to vanilla state before any cleanup
  2. Unbind listeners second: Prevents stale event handlers from firing during teardown
  3. Destroy DOM third: Removes visual elements while preserving original checkbox
  4. Re-resolve options fourth: Captures any HTML attribute changes since construction
  5. Create fresh state fifth: Resets toggle to initial state (matches current behavior)
  6. Rebuild DOM sixth: Creates new toggle structure with current options/state
  7. Rebind listeners seventh: Attaches fresh handlers to new DOM
  8. Re-intercept properties last: Restores change detection on the rebuilt element

Testing Recommendations

Unit test scenarios to add/update:

  1. Instance identity preservation:

    const toggle = new Toggle(element);
    const instanceRef = toggle;
    toggle.rerender();
    assert.strictEqual(toggle, instanceRef);
    assert.strictEqual(element.bsToggle, instanceRef);
  2. Option re-resolution:

    const toggle = new Toggle(element, { onlabel: "Old" });
    element.setAttribute("data-onlabel", "New");
    toggle.rerender();
    // Verify DOM shows "New" label
  3. Error propagation:

    const toggle = new Toggle(element);
    // Simulate DOMBuilder failure (e.g., remove parent element)
    element.remove();
    assert.throws(() => toggle.rerender());
  4. Event listener cleanup verification:

    const toggle = new Toggle(element);
    const oldListenersCount = getListenerCount(element);
    toggle.rerender();
    // Verify no listener leaks (count same as after initial construction)
  5. State reset verification:

    const toggle = new Toggle(element);
    toggle.on();  // Change state to ON
    toggle.rerender();
    // Verify toggle returns to initial (off/on based on options)

Additional Comments

Relationship to Lifecycle Migration Feature

This refactor is a mandatory prerequisite for the #317. The lifecycle migration will:

  1. Require rerender() to work without destroying the instance (this refactor provides that)
  2. Replace manual destroy() calls with super.destroy() + lifecycle hooks
  3. Introduce doInit(), doAttach(), doDispose(), doDestroy() hooks

Without this refactor, the lifecycle migration would be impossible because rerender() would bypass the state machine entirely.

Risk Assessment

Risk Likelihood Impact Mitigation
Missing listener cleanup Low Medium Verify unbindEventListeners() covers all listeners (already validated)
State inconsistency after partial failure Low High Errors propagate; instance may be inconsistent but this matches current behavior (new instance would have been created, old instance destroyed)
Performance regression Very low Low Reuses instance; may actually be faster (no GC of old instance)
Regression in existing tests Low Medium Run full test suite; update any tests that assumed rerender() changes instance identity

Dependencies

  • No new dependencies required
  • No peer dependency changes
  • No CSS/HTML changes

Out of Scope

  • Adding error recovery logic (try/catch)
  • Making rerender() return this for chaining
  • Emitting toggle:rerendered events
  • Making rerender() async
  • Adding lifecycle state guards (lifecycle not yet implemented)
  • Modifying any other methods (destroy(), toggle(), on(), etc.)
  • Changing the method signature or parameters

Feature Request Checklist

  • Confirm that you agree to follow the project's code of conduct.
  • Confirm that you have reviewed open and rejected feature requests to ensure novelty.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    Status

    Needs triage

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions