Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#4258: Remove trigger listeners after invalidation #7675

Merged
merged 8 commits into from
Feb 22, 2024

Conversation

fregante
Copy link
Contributor

@fregante fregante commented Feb 20, 2024

What does this PR do?

Demo

wip, there's an existing behavior issue to solve first

Checklist

  • Designate a primary reviewer: @mnholtz

id: this.id,
instanceNonce: this.instanceNonce,
});

Copy link
Contributor Author

@fregante fregante Feb 20, 2024

Choose a reason for hiding this comment

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

The test for this file is failing on watch because:

  • before: the events would be removed in the uninstall function
  • now: the events are removed in cancelObservers (via abortcontroller)

The difference is that cancelObservers is being called by runModComponents. Is that supposed to happen?

await extensionPoint.install();
await extensionPoint.runModComponents({ reason: RunReason.MANUAL });
expect(rootReader.readCount).toBe(0);
screen.getByRole("button").click();
await tick();
expect(rootReader.readCount).toBe(1);

Are the tests calling the wrong function? Why is runModComponents calling cancelObservers even on watch?

async runModComponents(): Promise<void> {
this.cancelObservers();

Copy link
Contributor Author

Choose a reason for hiding this comment

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

cc @twschiller because both snippets were written by you, you might have more context.

Copy link
Contributor

@twschiller twschiller Feb 20, 2024

Choose a reason for hiding this comment

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

I think the trigger extension point might need 2 layers of abort controllers?

  1. Overall abort controller
  2. Abort controller for the mutation observer/intersection observer(s) added in the appear trigger and "watch". (Or maybe just the "watch" mutation observers

Why is runModComponents calling cancelObservers

On SPA navigation, lifecycle calls runModComponents. The cancelObservers call aborts the observer/intersection observer so they can be re-added

I think it might be valid just to keep those registered on SPA navigation if they're already registered. But you'd definitely need logic to avoid re-adding the mutation/intersection observer. I don't remember exactly, but I probably just took the route of removing/re-adding vs. keeping track of state because

Also, the removing/re-adding would be resilient to changes in the trigger selector. (Although the trigger is readonly for trigger instances. If the trigger changes via the Page Editor IIRC the old extension point gets uninstalled)

Corner cases to consider:

  • What happens on SPA navigation to a URL that's not targeted by the trigger - if this is not already handled, it should be handled by lifecycle.ts calling uninstall:
    if (!(await extensionPoint.isAvailable())) {
  • What happens on Page Editor edit - IIRC, this already works because uninstall is called on the extension point?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think I'll do with the dual controller to replicate the previous behavior. I wouldn't want to change the logic further in this PR.

$elements.on(domEventName, this.eventHandler);
for (const element of $elements) {
element.addEventListener(domEventName, this.eventHandler, {
signal: this.abortController.signal,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The signal here is what's failing the tests. If I keep the events until uninstall is called, it doesn't fail, but then what is cancelObserver for?

Copy link
Contributor

Choose a reason for hiding this comment

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

it doesn't fail, but then what is cancelObserver for?

It's specifically for the mutation/intersection observers: #7675 (comment)

@@ -768,17 +746,22 @@ export abstract class TriggerStarterBrickABC extends StarterBrickABC<TriggerConf
$elements.off("mouseenter.hoverIntent");
$elements.off("mouseleave.hoverIntent");
$elements.hoverIntent({
over: this.eventHandler,
over: async ({ originalEvent }) => this.eventHandler(originalEvent),
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The previous piece of code used event.originalEvent everywhere. This simplifies it by calling it once.

initializationInterval = undefined;
}
clearInterval(initializationInterval);
initializationInterval = undefined;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

No need to do a null-check, as long as the type isn't null (which isn't)

this.addEventListener(this.coreEvent, this.getNativeListener(callback));
add(
callback: SimpleEventListener<Detail>,
options?: AddEventListenerOptions,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added support for {signal} etc

@fregante fregante added the bug Something isn't working label Feb 20, 2024
@fregante fregante requested a review from mnholtz February 20, 2024 12:56
@fregante fregante marked this pull request as ready for review February 20, 2024 12:57
@@ -35,30 +36,17 @@ const MAX_MANAGED_STORAGE_WAIT_MILLIS = 4500;
/**
* Interval for checking managed storage initialization that takes longer than MAX_MANAGED_STORAGE_WAIT_MILLIS seconds.
*/
let initializationInterval: Nullishable<ReturnType<typeof setTimeout>>;
let initializationInterval: ReturnType<typeof setTimeout> | undefined;
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd recommend calling out that it should be undefined instead of Nullishable because of clearInterval has different behavior on null vs. undefined: https://github.com/pixiebrix/pixiebrix-extension/pull/7675/files#r1495773879

Copy link
Contributor Author

@fregante fregante Feb 22, 2024

Choose a reason for hiding this comment

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

I can do it, but:

  • we shouldn't really prefer Nullishable over the single types (X | null OR X | undefined), but only if the type is necessarily X | null | undefined
  • it's not that clearInterval has a different behavior, but the types don't allow null, so the change wouldn't pass the types checker

So as long as we don't go around replacing | undefined with Nullishable, this isn't an issue (and even then, tsc will immediately complain)

Copy link
Contributor

@twschiller twschiller Feb 22, 2024

Choose a reason for hiding this comment

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

we shouldn't really prefer Nullishable over the single types (X | null OR X | undefined), but only if the type is necessarily X | null | undefined

@grahamlangford to avoid future bike-shedding I recommend we decide an approach and enforce via eslint

I agree with @fregante that's unnecessarily expanding types isn't valuable. The counter principle for our own code is that we by and large shouldn't be writing code where the distinction between null vs. undefined matters

}
}
// Used only for testing
let controller = new AbortController();
Copy link
Contributor

@twschiller twschiller Feb 20, 2024

Choose a reason for hiding this comment

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

Where does the abort controller get used? I'm not seeing it get passed to the event target or interval?

Copy link
Collaborator

Choose a reason for hiding this comment

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

It's being used in INTERNAL_reset? Federico's comment mentions its only used for testing. Or maybe you reviewed this before he added that comment?

@@ -342,10 +330,9 @@ export abstract class SidebarStarterBrickABC extends StarterBrickABC<SidebarConf

/**
* Shared event handler for DOM event triggers.
* It's bound to this instance so that it can be removed when the extension is uninstalled.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this what you mean in our newest terminology?

Suggested change
* It's bound to this instance so that it can be removed when the extension is uninstalled.
* It's bound to this instance so that it can be removed when the mod is deactivated.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Maybe? The file is named -Extension and the class has a uninstall method, so we should probably match those (one way or another)

Copy link
Collaborator

Choose a reason for hiding this comment

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

Part of the continuing naming/domain modeling work is to move from installed/uninstalled -> activated/deactivated.

We haven't really discussed changing the class methods though.

Copy link
Contributor Author

@fregante fregante Feb 22, 2024

Choose a reason for hiding this comment

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

I'll update the comment as Misha suggested for now because that's more likely to be left behind when the methods change.

Copy link
Contributor Author

@fregante fregante left a comment

Choose a reason for hiding this comment

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

Ready


// Capture state at the moment of the action
const state = listenerApi.getState();

abortController = new AbortController();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I noticed that we have this pattern of creating, aborting, and recreating abort controllers. I thought it could be wrapped up and clarified in a single pattern:

https://github.com/fregante/abort-utils/blob/main/source/repeatable-abort-controller.md

Thoughts on naming welcome.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thoughts on naming welcome.

I think the naming is good enough. I'm not sure how generally applicable it will be, but worth refactoring

@@ -255,7 +255,6 @@ export abstract class MenuItemStarterBrickABC extends StarterBrickABC<MenuItemSt
super(metadata, logger);
this.menus = new Map<UUID, HTMLElement>();
this.removed = new Set<UUID>();
this.cancelController = new AbortController();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

By the way we should move all of these initializations to the class itself. This is now possible:

class {
   map = new Map()
}

instead of:

class {
   map: Map;
   constructor() {
     this.map = new Map();
   }
}

*/
private abortController = new AbortController();
private readonly cancelHandlers = new RepeatableAbortController();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the dual-controller setup I mentioned, which fixes the tests

Copy link

codecov bot commented Feb 22, 2024

Codecov Report

Attention: Patch coverage is 86.36364% with 9 lines in your changes are missing coverage. Please review.

Project coverage is 72.57%. Comparing base (a8d3c3b) to head (b207c5b).
Report is 2 commits behind head on main.

Files Patch % Lines
src/starterBricks/sidebarExtension.ts 66.66% 4 Missing ⚠️
src/starterBricks/triggerExtension.ts 88.23% 2 Missing ⚠️
src/store/enterprise/managedStorage.ts 83.33% 2 Missing ⚠️
src/starterBricks/panelExtension.ts 66.66% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7675      +/-   ##
==========================================
+ Coverage   72.54%   72.57%   +0.02%     
==========================================
  Files        1259     1259              
  Lines       39264    39244      -20     
  Branches     7326     7321       -5     
==========================================
- Hits        28484    28480       -4     
+ Misses      10780    10764      -16     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@twschiller twschiller added this to the 1.8.10 milestone Feb 22, 2024
Copy link

No loom links were found in the first post. Please add one there if you'd like to it to appear on Slack.

Do not edit this comment manually.

@twschiller twschiller merged commit 38df206 into main Feb 22, 2024
26 checks passed
@twschiller twschiller deleted the F/bug/remove-triggers branch February 22, 2024 22:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working developer experience
Development

Successfully merging this pull request may close these issues.

4 participants