Skip to content

perf(date-picker): defer flatpickr destroy to survive portal-driven DOM moves#4000

Merged
Kiarokh merged 1 commit intomainfrom
854-fix-flatpickr-memory-leak
Apr 8, 2026
Merged

perf(date-picker): defer flatpickr destroy to survive portal-driven DOM moves#4000
Kiarokh merged 1 commit intomainfrom
854-fix-flatpickr-memory-leak

Conversation

@jgroth
Copy link
Copy Markdown
Contributor

@jgroth jgroth commented Apr 2, 2026

Summary by CodeRabbit

  • New Features

    • Date picker now defers teardown when removed, improving stability during rapid removal/reconnection.
  • Bug Fixes

    • Safer handling when the picker is destroyed or absent (prevents unexpected calls and focus issues).
    • Improved event listener cleanup order to avoid race conditions during component teardown.
    • Cancels pending teardown when reconnected to avoid accidental destruction.
  • Tests

    • Added tests covering deferred destruction timing, cancellation, overwrite behavior, and ordering edge cases.

Review:

  • Commits are atomic
  • Commits have the correct type for the changes made
  • Commits with breaking changes are marked as such

Browsers tested:

Windows:

  • Chrome
  • Edge
  • Firefox

Linux:

  • Chrome
  • Firefox

macOS:

  • Chrome
  • Firefox
  • Safari

Mobile:

  • Chrome on Android
  • iOS

Closes Lundalogik/crm-client#854

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 2, 2026

📝 Walkthrough

Walkthrough

Adds a new DeferredDestroy utility and tests; integrates it into DatePickerCalendar to defer picker teardown on disconnect and cancel on reconnect. Also adds defensive guards and nulling in Picker methods and reorders destroy/unbind steps in Month/Quarter/Year picker subclasses.

Changes

Cohort / File(s) Summary
DeferredDestroy & tests
src/components/date-picker/flatpickr-adapter/deferred-destroy.ts, src/components/date-picker/flatpickr-adapter/deferred-destroy.spec.ts
Adds exported DeferredDestroy class with schedule(callback) and cancel(); adds Vitest suite verifying deferred execution, cancellation, overwrite behavior, and safe repeated usage.
DatePickerCalendar integration
src/components/date-picker/flatpickr-adapter/flatpickr-adapter.tsx
Introduces deferredDestroy instance; adds connectedCallback() to cancel pending teardown and defers picker destruction in disconnectedCallback() via deferredDestroy.schedule(...).
Picker defensive updates
src/components/date-picker/pickers/picker.ts
Uses optional chaining for flatpickr accesses in setValue, redraw, handleOnClose; destroy() clears this.flatpickr = null after teardown.
Picker subclass destroy order changes
src/components/date-picker/pickers/month-picker.tsx, src/components/date-picker/pickers/quarter-picker.tsx, src/components/date-picker/pickers/year-picker.tsx
Moves custom Flatpickr event listener removal to occur before calling super.destroy() (changes teardown ordering).

Sequence Diagram

sequenceDiagram
    participant DOM as DOM Lifecycle
    participant Calendar as DatePickerCalendar
    participant Deferred as DeferredDestroy
    participant Picker as Picker

    DOM->>Calendar: disconnectedCallback()
    Calendar->>Deferred: schedule(destroyCallback)
    Deferred->>Deferred: setTimeout(callback, 0)
    Note over Deferred: Destruction pending...

    alt Element reconnected before timeout
        DOM->>Calendar: connectedCallback()
        Calendar->>Deferred: cancel()
        Deferred->>Deferred: clear pending timeout
        Note over Calendar: Destruction prevented
    else Timeout fires
        Deferred->>Picker: invoke destroyCallback()
        Picker->>Picker: remove listeners / clean up
        Picker->>Picker: flatpickr = null
        Note over Calendar: Destruction completed
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically identifies the main change: deferring flatpickr destruction to handle portal-driven DOM moves, which aligns directly with the core implementation across all modified files.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 854-fix-flatpickr-memory-leak

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 2, 2026

Documentation has been published to https://lundalogik.github.io/lime-elements/versions/PR-4000/

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/date-picker/pickers/picker.ts (1)

77-84: ⚠️ Potential issue | 🟠 Major

destroy() now breaks subclass cleanup paths.

Line 83 nulls this.flatpickr immediately, but src/components/date-picker/pickers/year-picker.tsx (Line 41-53) performs additional listener cleanup after super.destroy(). That cleanup now becomes a no-op and can leave custom handlers unremoved.

Suggested fix (preserve subclass teardown hook before nulling)
 export abstract class Picker {
+    protected beforeDestroy(_instance: flatpickr.Instance): void {
+        // Subclasses can override for custom cleanup.
+    }
+
     public destroy() {
         if (!this.flatpickr) {
             return;
         }
 
-        this.flatpickr.destroy();
-        this.flatpickr = null;
+        const instance = this.flatpickr;
+        this.beforeDestroy(instance);
+        instance.destroy();
+        this.flatpickr = null;
     }
 }

Then move YearPicker-specific listener removal into beforeDestroy.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/date-picker/pickers/picker.ts` around lines 77 - 84, The
current destroy() implementation nulls this.flatpickr immediately which prevents
subclass cleanup (e.g., YearPicker in year-picker.tsx that calls super.destroy()
and then removes listeners); modify the base class (picker.ts) so destroy()
calls a protected hook method (e.g., beforeDestroy()) while this.flatpickr is
still intact, then perform this.flatpickr.destroy() and only null this.flatpickr
after calling beforeDestroy(); update YearPicker to move its listener removal
logic from after super.destroy() into an override of beforeDestroy() so its
cleanup runs while flatpickr is available.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/components/date-picker/pickers/picker.ts`:
- Around line 77-84: The current destroy() implementation nulls this.flatpickr
immediately which prevents subclass cleanup (e.g., YearPicker in year-picker.tsx
that calls super.destroy() and then removes listeners); modify the base class
(picker.ts) so destroy() calls a protected hook method (e.g., beforeDestroy())
while this.flatpickr is still intact, then perform this.flatpickr.destroy() and
only null this.flatpickr after calling beforeDestroy(); update YearPicker to
move its listener removal logic from after super.destroy() into an override of
beforeDestroy() so its cleanup runs while flatpickr is available.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 67ec2bd7-6889-4f90-87aa-3d89da7ef28a

📥 Commits

Reviewing files that changed from the base of the PR and between 603788b and 37895a5.

📒 Files selected for processing (4)
  • src/components/date-picker/flatpickr-adapter/deferred-destroy.spec.ts
  • src/components/date-picker/flatpickr-adapter/deferred-destroy.ts
  • src/components/date-picker/flatpickr-adapter/flatpickr-adapter.tsx
  • src/components/date-picker/pickers/picker.ts

@jgroth jgroth force-pushed the 854-fix-flatpickr-memory-leak branch from 37895a5 to a8dd181 Compare April 2, 2026 12:15
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/date-picker/flatpickr-adapter/flatpickr-adapter.tsx`:
- Line 75: The field deferredDestroy is initialized once and never reassigned;
mark it readonly to reflect immutability. Update the class field declaration for
deferredDestroy (the instance of DeferredDestroy) to be readonly so that the
compiler enforces it cannot be reassigned while preserving current
initialization and usage in the class.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 820c9b3d-e381-4b3e-b82d-b1c49207a171

📥 Commits

Reviewing files that changed from the base of the PR and between 37895a5 and a8dd181.

📒 Files selected for processing (7)
  • src/components/date-picker/flatpickr-adapter/deferred-destroy.spec.ts
  • src/components/date-picker/flatpickr-adapter/deferred-destroy.ts
  • src/components/date-picker/flatpickr-adapter/flatpickr-adapter.tsx
  • src/components/date-picker/pickers/month-picker.tsx
  • src/components/date-picker/pickers/picker.ts
  • src/components/date-picker/pickers/quarter-picker.tsx
  • src/components/date-picker/pickers/year-picker.tsx

Comment thread src/components/date-picker/flatpickr-adapter/flatpickr-adapter.tsx
…OM moves

When a portal moves a DOM element, the browser fires
`disconnectedCallback` followed by `connectedCallback` in the same
microtask. The immediate `destroy()` in `disconnectedCallback` killed
the flatpickr instance permanently, even though the element was about
to be reattached.

`DeferredDestroy` pushes the teardown into a `setTimeout(0)` so that
`connectedCallback` can cancel it if the element is reconnected.
`Picker` methods now guard against a nulled-out flatpickr instance.

Co-Authored-By: Claude <noreply@anthropic.com>
@Kiarokh Kiarokh force-pushed the 854-fix-flatpickr-memory-leak branch from a8dd181 to 01a0d84 Compare April 8, 2026 06:58
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/date-picker/pickers/picker.ts (1)

23-23: ⚠️ Potential issue | 🟠 Major

Change field type to flatpickr.Instance | null to match runtime behavior.

Line 23 declares flatpickr as non-nullable, but Line 83 assigns null in the destroy() method. The type declaration should reflect this:

protected flatpickr: flatpickr.Instance | null;

This aligns with the code's actual usage patterns, where optional chaining (?.) is employed in methods like setValue() and redraw(), and subclasses conditionally access the field after potential destruction.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/date-picker/pickers/picker.ts` at line 23, The flatpickr field
is declared as non-nullable but assigned null in destroy(), so change the
declaration of the protected field flatpickr on the Picker class from
flatpickr.Instance to flatpickr.Instance | null to match runtime behavior;
update the type for the field declaration (referencing the protected flatpickr
property) so usages in destroy(), setValue(), redraw(), and any subclass access
are type-correct without needing unsafe non-null assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/date-picker/flatpickr-adapter/deferred-destroy.spec.ts`:
- Line 22: Rename the test title to use "timer tick" or "macrotask" instead of
"microtask": update the test whose description currently reads "schedule calls
the callback after the microtask completes" to something like "schedule calls
the callback after the timer tick completes" (or "after the macrotask
completes") so it accurately reflects that setTimeout(..., 0) runs on the
timer/macrotask queue; keep the rest of the test (the schedule invocation and
assertions in deferred-destroy.spec.ts) unchanged and only change the string
passed to test(...).

---

Outside diff comments:
In `@src/components/date-picker/pickers/picker.ts`:
- Line 23: The flatpickr field is declared as non-nullable but assigned null in
destroy(), so change the declaration of the protected field flatpickr on the
Picker class from flatpickr.Instance to flatpickr.Instance | null to match
runtime behavior; update the type for the field declaration (referencing the
protected flatpickr property) so usages in destroy(), setValue(), redraw(), and
any subclass access are type-correct without needing unsafe non-null assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0579ce97-8d59-45f1-b010-8f4a1b63395c

📥 Commits

Reviewing files that changed from the base of the PR and between a8dd181 and 01a0d84.

📒 Files selected for processing (7)
  • src/components/date-picker/flatpickr-adapter/deferred-destroy.spec.ts
  • src/components/date-picker/flatpickr-adapter/deferred-destroy.ts
  • src/components/date-picker/flatpickr-adapter/flatpickr-adapter.tsx
  • src/components/date-picker/pickers/month-picker.tsx
  • src/components/date-picker/pickers/picker.ts
  • src/components/date-picker/pickers/quarter-picker.tsx
  • src/components/date-picker/pickers/year-picker.tsx

expect(callback).not.toHaveBeenCalled();
});

test('schedule calls the callback after the microtask completes', () => {
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.

⚠️ Potential issue | 🟡 Minor

Use “timer tick/macrotask” wording instead of “microtask” in the test name.

Line 22 currently describes microtask timing, but setTimeout(..., 0) executes on the timer queue.

Suggested wording update
-test('schedule calls the callback after the microtask completes', () => {
+test('schedule calls the callback on the next timer tick', () => {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('schedule calls the callback after the microtask completes', () => {
test('schedule calls the callback on the next timer tick', () => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/date-picker/flatpickr-adapter/deferred-destroy.spec.ts` at
line 22, Rename the test title to use "timer tick" or "macrotask" instead of
"microtask": update the test whose description currently reads "schedule calls
the callback after the microtask completes" to something like "schedule calls
the callback after the timer tick completes" (or "after the macrotask
completes") so it accurately reflects that setTimeout(..., 0) runs on the
timer/macrotask queue; keep the rest of the test (the schedule invocation and
assertions in deferred-destroy.spec.ts) unchanged and only change the string
passed to test(...).

Copy link
Copy Markdown
Contributor

@Kiarokh Kiarokh left a comment

Choose a reason for hiding this comment

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

testing and things are working fine.
AI reviews just pointed out ignorable nipicks in the tests

@Kiarokh Kiarokh merged commit 48c5462 into main Apr 8, 2026
10 checks passed
@Kiarokh Kiarokh deleted the 854-fix-flatpickr-memory-leak branch April 8, 2026 07:18
@lime-opensource
Copy link
Copy Markdown
Collaborator

🎉 This PR is included in version 39.11.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants