Skip to content

fix(overlay): default listenerHost to target in overlay-trigger-directive#5873

Merged
TarunAdobe merged 10 commits intomainfrom
ttomar/trigger-directive
Jan 19, 2026
Merged

fix(overlay): default listenerHost to target in overlay-trigger-directive#5873
TarunAdobe merged 10 commits intomainfrom
ttomar/trigger-directive

Conversation

@TarunAdobe
Copy link
Contributor

@TarunAdobe TarunAdobe commented Nov 11, 2025

Description

Fixes OverlayTriggerDirective behavior when used with Lit's cache() directive. The directive now properly cleans up overlay state on disconnect and allows fresh overlay creation on reconnect, preventing stale state and runtime errors.

Motivation and context

When using Lit's cache() directive with overlay trigger elements, the directive's lifecycle behaves differently than with standard rendering:

Without cache():

  • Elements are completely destroyed and recreated
  • The directive instance is created fresh each time
  • reconnected() never runs on restoration

With cache():

  • Elements are hidden (kept in memory) instead of destroyed
  • When restored, disconnected() and reconnected() lifecycle hooks fire
  • reconnected() attempts to reinitialize by calling init()

When a cached trigger element was disconnected and reconnected, the overlay instance remained in a stale state. On reconnect, the overlay reference persisted but:
The overlay DOM element was orphaned or in an inconsistent state
The handleOverlayReady callback wouldn't fire again for the stale overlay
Attempting to reuse the old overlay caused errors and prevented the overlay from opening

Solution:

On disconnected():

  • Close the overlay to clean up any open state

  • Remove the overlay element from the DOM

  • Clear the overlay reference in the strategy by setting _overlay = undefined
    On reconnected():

  • Do nothing - the overlay reference was cleared, so the next click will create a fresh overlay

  • The handleOverlayReady callback will fire again with the new overlay instance

  • Everything works as if it's the first time opening

This approach ensures that cached trigger elements always work correctly through multiple hide/show cycles. The strategy's persistent click listeners remain active (as intended), but the overlay is freshly created each time it's needed after a disconnect.

Related issue(s)

Manual Testing Steps

  • Open this story
  • Click "Toggle Overlay Render Button" multiple times to hide/show the trigger button
  • Verify there are no console errors or warnings
  • Click "Toggle Popover" button to ensure the overlay still functions correctly

Author's checklist

  • I have read the CONTRIBUTING and PULL_REQUESTS documents.
  • I have reviewed at the Accessibility Practices for this feature, see: Aria Practices
  • I have added automated tests to cover my changes.
  • I have included a well-written changeset if my change needs to be published.
  • I have included updated documentation if my change required it.

Reviewer's checklist

  • Includes a Github Issue with appropriate flag or Jira ticket number without a link
  • Includes thoughtfully written changeset if changes suggested include patch, minor, or major features
  • Automated tests cover all use cases and follow best practices for writing
  • Validated on all supported browsers
  • All VRTs are approved before the author can update Golden Hash

@changeset-bot
Copy link

changeset-bot bot commented Nov 11, 2025

🦋 Changeset detected

Latest commit: bd63ae7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 78 packages
Name Type
@spectrum-web-components/overlay Patch
@spectrum-web-components/action-menu Patch
@spectrum-web-components/combobox Patch
@spectrum-web-components/contextual-help Patch
@spectrum-web-components/menu Patch
@spectrum-web-components/picker Patch
@spectrum-web-components/popover Patch
@spectrum-web-components/tooltip Patch
@spectrum-web-components/bundle Patch
@spectrum-web-components/truncated Patch
@spectrum-web-components/breadcrumbs Patch
@spectrum-web-components/action-bar Patch
@spectrum-web-components/card Patch
@spectrum-web-components/coachmark Patch
@spectrum-web-components/accordion Patch
@spectrum-web-components/action-button Patch
@spectrum-web-components/action-group Patch
@spectrum-web-components/alert-banner Patch
@spectrum-web-components/alert-dialog Patch
@spectrum-web-components/asset Patch
@spectrum-web-components/avatar Patch
@spectrum-web-components/badge Patch
@spectrum-web-components/button-group Patch
@spectrum-web-components/button Patch
@spectrum-web-components/checkbox Patch
@spectrum-web-components/clear-button Patch
@spectrum-web-components/close-button Patch
@spectrum-web-components/color-area Patch
@spectrum-web-components/color-field Patch
@spectrum-web-components/color-handle Patch
@spectrum-web-components/color-loupe Patch
@spectrum-web-components/color-slider Patch
@spectrum-web-components/color-wheel Patch
@spectrum-web-components/dialog Patch
@spectrum-web-components/divider Patch
@spectrum-web-components/dropzone Patch
@spectrum-web-components/field-group Patch
@spectrum-web-components/field-label Patch
@spectrum-web-components/help-text Patch
@spectrum-web-components/icon Patch
@spectrum-web-components/icons-ui Patch
@spectrum-web-components/icons-workflow Patch
@spectrum-web-components/icons Patch
@spectrum-web-components/iconset Patch
@spectrum-web-components/illustrated-message Patch
@spectrum-web-components/infield-button Patch
@spectrum-web-components/link Patch
@spectrum-web-components/meter Patch
@spectrum-web-components/modal Patch
@spectrum-web-components/number-field Patch
@spectrum-web-components/picker-button Patch
@spectrum-web-components/progress-bar Patch
@spectrum-web-components/progress-circle Patch
@spectrum-web-components/radio Patch
@spectrum-web-components/search Patch
@spectrum-web-components/sidenav Patch
@spectrum-web-components/slider Patch
@spectrum-web-components/split-view Patch
@spectrum-web-components/status-light Patch
@spectrum-web-components/swatch Patch
@spectrum-web-components/switch Patch
@spectrum-web-components/table Patch
@spectrum-web-components/tabs Patch
@spectrum-web-components/tags Patch
@spectrum-web-components/textfield Patch
@spectrum-web-components/thumbnail Patch
@spectrum-web-components/toast Patch
@spectrum-web-components/top-nav Patch
@spectrum-web-components/tray Patch
@spectrum-web-components/underlay Patch
@spectrum-web-components/base Patch
@spectrum-web-components/grid Patch
@spectrum-web-components/opacity-checkerboard Patch
@spectrum-web-components/reactive-controllers Patch
@spectrum-web-components/shared Patch
@spectrum-web-components/styles Patch
@spectrum-web-components/theme Patch
@spectrum-web-components/eslint-plugin Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Contributor

github-actions bot commented Nov 11, 2025

📚 Branch Preview

🔍 Visual Regression Test Results

When a visual regression test fails (or has previously failed while working on this branch), its results can be found in the following URLs:

Deployed to Azure Blob Storage: pr-5873

If the changes are expected, update the current_golden_images_cache hash in the circleci config to accept the new images. Instructions are included in that file.
If the changes are unexpected, you can investigate the cause of the differences and update the code accordingly.

@TarunAdobe TarunAdobe force-pushed the ttomar/trigger-directive branch from b089a7b to 678b431 Compare November 11, 2025 10:00
@TarunAdobe TarunAdobe marked this pull request as ready for review November 11, 2025 10:01
@TarunAdobe TarunAdobe requested a review from a team as a code owner November 11, 2025 10:01
Rajdeepc

This comment was marked as outdated.

@Rajdeepc Rajdeepc added the Status:Ready for review PR ready for review or re-review. label Nov 11, 2025
// Now click the cached trigger to open the overlay.
const opened = oneEvent(cachedTrigger, 'sp-opened');
cachedTrigger.click();
await opened;
Copy link
Contributor

Choose a reason for hiding this comment

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

can we add some assertions after this to confirm the popover is rendered?

Copy link
Contributor

Choose a reason for hiding this comment

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

still missing the assertions for popover being open

Copy link
Contributor

Choose a reason for hiding this comment

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

@TarunAdobe Either you check for the overlay opened or check if the sp-opened is dispatched or not. Either of them will suffice.

Copy link
Contributor

@rubencarvalho rubencarvalho Jan 8, 2026

Choose a reason for hiding this comment

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

I unresolved the thread because I still don't see the assertion here 😅

this.target = part.element as HTMLElement;
newTarget = true;
}
this.listenerHost = this.target;
Copy link
Contributor

Choose a reason for hiding this comment

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

are we sure this is the correct default? im trying to understand the override but the strategies and controller complexity is making my brain warp. can we chat about this in our team meeting thursday?

Copy link
Contributor

@Rajdeepc Rajdeepc Nov 13, 2025

Choose a reason for hiding this comment

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

The type error occurs because this.listenerHost is undefined when reconnected() → init() runs before the overlay is ready.
This happens because the parent class sets this.listenerHost = this.target in update(), child class overrides update() but only sets this.listenerHost inside the async handleOverlayReady callback. If reconnection happens before the overlay exists → this.listenerHost is undefined and it will error out.

this.listenerHost = this.target is not architecturally correct coz the slottable-request event is dispatched from the overlay element with bubbles: false.

Attaching the listener to this.target (the trigger button) means it will never receive the event since it doesn't bubble. This breaks the lazy content loading mechanism entirely.

We can override the reconnected() in the child class to guard against premature initialization, its cleaner since it's specific to the child's async overlay setup pattern.

override reconnected(): void {
    // Only call init() if the overlay is ready
    if (this.listenerHost) {
        this.init();
    }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

good job finding a solution @Rajdeepc !!! I updated the change.

@Rajdeepc Rajdeepc dismissed their stale review November 13, 2025 09:05

Revisiting coz i see the events don't bubble from parent 'slottable-requestand the listener needs to be on the overlay. there are two ways to do this, we can wait for the overlay to exist before attaching the listener or guard against callinginit()` method.

@TarunAdobe TarunAdobe force-pushed the ttomar/trigger-directive branch from 786ae5d to 0477afd Compare November 17, 2025 13:30
@caseyisonit
Copy link
Contributor

@TarunAdobe can you include the manual testing steps in the PR description?

@caseyisonit
Copy link
Contributor

When testing overlay from this link: https://swcpreviews.z13.web.core.windows.net/pr-5873/docs/storybook/?path=/story/overlay-directive--default

The overlay does not open and im receiving this in the console:
Screenshot 2025-11-17 at 2 25 10 PM

@github-actions
Copy link
Contributor

github-actions bot commented Nov 18, 2025

📚 Branch Preview Links

🔍 First Generation Visual Regression Test Results

When a visual regression test fails (or has previously failed while working on this branch), its results can be found in the following URLs:

Deployed to Azure Blob Storage: pr-5873

If the changes are expected, update the current_golden_images_cache hash in the circleci config to accept the new images. Instructions are included in that file.
If the changes are unexpected, you can investigate the cause of the differences and update the code accordingly.

@TarunAdobe
Copy link
Contributor Author

When testing overlay from this link: https://swcpreviews.z13.web.core.windows.net/pr-5873/docs/storybook/?path=/story/overlay-directive--default

The overlay does not open and im receiving this in the console: Screenshot 2025-11-17 at 2 25 10 PM

WE TOTALLY DIDN'T KNOW BT THIS IS A PROBLEM IN MAIN TOO!!

@TarunAdobe
Copy link
Contributor Author

@caseyisonit the error you saw in the story would be fixed in a separate pr #5896

@TarunAdobe
Copy link
Contributor Author

@TarunAdobe can you include the manual testing steps in the PR description?

I updated the Managed overlay trigger story implementation so that it now caches the overlay trigger instead of replacing it and thus you can verify this fix from that story (check description again).

@TarunAdobe TarunAdobe force-pushed the ttomar/trigger-directive branch from 4b22517 to 1c8d9c0 Compare November 19, 2025 06:49
@Rajdeepc
Copy link
Contributor

@lehelen19 Could you help validate this in Hz before we move forward with the rollout? It would also be helpful if you could share your specific use case. We can then add it to our Storybook dev instance to reproduce the scenario and run additional tests on our side. This issue appears to be highly use-case specific, so any additional context will help us ensure full coverage.

@lehelen19
Copy link
Contributor

The bug was triggering errors logged in Splunk, so I don't have a specific use case to share

@TarunAdobe TarunAdobe force-pushed the ttomar/trigger-directive branch from d4cfab8 to bbe64a9 Compare November 25, 2025 05:09
@Rajdeepc
Copy link
Contributor

The bug was triggering errors logged in Splunk, so I don't have a specific use case to share

Thanks for the context. @lehelen19 I want to do a soft test here before we roll out this fix. Can you confirm if this fix works for you or not?

@lehelen19
Copy link
Contributor

lehelen19 commented Dec 4, 2025

Thanks for the context. @lehelen19 I want to do a soft test here before we roll out this fix. Can you confirm if this fix works for you or not?

I don't have a specific test case for this issue. You might be able to remove a component with the overlay directive and then reconnect it to see if you are encountering the error in the console?

We should be able to see the related Splunk error disappear once we get the fix in main

@TarunAdobe TarunAdobe force-pushed the ttomar/trigger-directive branch from e83e1df to dae7ed3 Compare January 6, 2026 06:17
Copy link
Contributor

@Rajdeepc Rajdeepc left a comment

Choose a reason for hiding this comment

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

Good work! Verified this but let's keep an eye out for any edge cases as this rolls out, but this looks solid from my side.

// Now click the cached trigger to open the overlay.
const opened = oneEvent(cachedTrigger, 'sp-opened');
cachedTrigger.click();
await opened;
Copy link
Contributor

Choose a reason for hiding this comment

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

@TarunAdobe Either you check for the overlay opened or check if the sp-opened is dispatched or not. Either of them will suffice.

@TarunAdobe
Copy link
Contributor Author

The story set up is confusing for me on what its actually doing and the differences between the two. Can we set this story up to be more reflective of an actual pattern that would exist in product? can the labels on the buttons be improved for understanding? the popover should also not include a heading, thats not a realistic use case.

I have updated the story with a better usecase that is closer to the product requirement. Please check the latest update.

Also the documentation markdown needs to be updated to reflect how and when to use caching for code snippets provided. The caching also requires the lit directive package and should be called out in the docs as well.

This is a bit nuanced and I don't fully agree if we need to add anything to the documentation as this is a certain pattern that is being used in a certain app. My expectation as a user is that overlay-directive would work for most / all common patterns and we don't all possible usescases of our components either way.

@rubencarvalho
Copy link
Contributor

I still see some weirdness

Uploading Screen Recording 2026-01-08 at 11.39.19.mov…

Copy link
Contributor

@rubencarvalho rubencarvalho left a comment

Choose a reason for hiding this comment

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

When disabling the quick actions with the menu open, re-enabling it will not make it work again.

@TarunAdobe TarunAdobe force-pushed the ttomar/trigger-directive branch 3 times, most recently from e878817 to 31101aa Compare January 8, 2026 16:07
// Clear the overlay reference in the strategy so a new one will be created
// on the next open, which will trigger handleOverlayReady again.
if (this.strategy) {
(this.strategy as unknown as { _overlay: undefined })._overlay =
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we consider properly typing _overlay as optional to avoid the cast?
Change private _overlay!: AbstractOverlay; to private _overlay?: AbstractOverlay and update the getter.
And if we really want to be idiomatic, we should add a public clearOverlay() method:

public clearOverlay(): void {
    if (this._overlay) {
        this._overlay.removeController(this);
    }
    this._overlay = undefined;
}

and then here we instead just call this.strategy?.clearOverlay();. It seems to me like a cleaner approach because it keeps the cleanup logic encapsulated in the controller (where it can also call removeController()).

Copy link
Contributor

Choose a reason for hiding this comment

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

lol i like this approach over my comment, we were reviewing at the same time

Comment on lines +157 to +165
override reconnected(): void {
// If overlay was never created (user never opened it), there's nothing to reconnect.
// The overlay and listenerHost are only set when handleOverlayReady fires,
// which happens on first open. Without this guard, init() would fail
// trying to add a listener to undefined listenerHost.
//
// If overlay WAS created before disconnect, we cleared the references in disconnected(),
// so this.overlay will be undefined and we'll skip init(). A fresh overlay will be
// created on the next open via handleOverlayReady.
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we maybe can modify this / add a sentence to call out the intention of the empty reconnected override?

IDK, something along the lines of

// Intentionally empty: don't call init() since the overlay reference was cleared in disconnected() and will be recreated on next open.

// Now click the cached trigger to open the overlay.
const opened = oneEvent(cachedTrigger, 'sp-opened');
cachedTrigger.click();
await opened;
Copy link
Contributor

@rubencarvalho rubencarvalho Jan 8, 2026

Choose a reason for hiding this comment

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

I unresolved the thread because I still don't see the assertion here 😅

Copy link
Contributor

@caseyisonit caseyisonit left a comment

Choose a reason for hiding this comment

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

looking so good, works great, but there's a private property override that needs to be adjusted to the public property. i tried to explain what i think a working solution might be but might take some tinkering.

directive, which preserves the DOM and component state when
toggled off instead of destroying it. This tests that the
overlay trigger properly handles reconnection without
errors.
Copy link
Contributor

Choose a reason for hiding this comment

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

Thank you for this update to this story! its a great example now!

// Clear the overlay reference in the strategy so a new one will be created
// on the next open, which will trigger handleOverlayReady again.
if (this.strategy) {
(this.strategy as unknown as { _overlay: undefined })._overlay =
Copy link
Contributor

Choose a reason for hiding this comment

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

This is overriding a private property in the interaction controller that is typed as AbstractController, missing undefined thus the need of the typescript.

There is a public overlay property that we can tap into and modify the first return in the setter to _overlay= undefined and add undefined as an accepted type value to the private property. i believe the getter might also need to accept undefined as well.


private prepareContentRelativeDescription(): void {
if (!this.overlay) return;
const overlay = this.overlay; // Capture for closure
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice! Easy to miss

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yus the linter cried otherwise i had missed it

@Rajdeepc Rajdeepc enabled auto-merge (squash) January 13, 2026 04:25
@Rajdeepc
Copy link
Contributor

@caseyisonit If you get some time today can you please review this. We would like to get this out for PsWeb and give them a alpha release to test

@TarunAdobe TarunAdobe force-pushed the ttomar/trigger-directive branch from 1e23669 to 3c6c8f8 Compare January 13, 2026 10:46
@TarunAdobe TarunAdobe disabled auto-merge January 15, 2026 06:24
@TarunAdobe TarunAdobe enabled auto-merge (squash) January 15, 2026 06:25
Copy link
Contributor

@caseyisonit caseyisonit left a comment

Choose a reason for hiding this comment

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

this is working as intended, i did notice a flicker of css styles when clicking the trigger to open (not close) but its negligible so approving!

@TarunAdobe TarunAdobe merged commit 02b2d7d into main Jan 19, 2026
20 of 21 checks passed
@TarunAdobe TarunAdobe deleted the ttomar/trigger-directive branch January 19, 2026 04:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Status:Ready for review PR ready for review or re-review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: listenerHost can be uninitialized in overlay-trigger-directive

5 participants