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

backporting the root implementation with memoize feature #80

Merged
merged 1 commit into from
May 3, 2018

Conversation

lynchbomb
Copy link
Member

  • Spike for back-porting the v3 root implementation within Intersection-Observer.
  • Handling occluded elements and child scroll containers.

Copy link
Contributor

@asakusuma asakusuma left a comment

Choose a reason for hiding this comment

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

I’ll take another pass later. A few questions:

  1. Does this change handle nested roots?
  2. I assume this handles the scenario where some other code captures the window scroll event?

USAGE.md Outdated
@@ -111,7 +111,8 @@ The `Watcher` constructor can be passed 3 different options:

* `time` - The time threshold
* `ratio` - The ratio threshold
* `rootMargin` - The [rootMargin](https://wicg.github.io/IntersectionObserver/#dom-intersectionobserverinit-rootmargin) in object form.
* `rootMargin` - The rootMargin in object form.
Copy link
Contributor

Choose a reason for hiding this comment

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

Why get rid of the link

Copy link
Member Author

Choose a reason for hiding this comment

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

It's 404-ing. FWIW rootMargin is as well. Just need to find the new relative links for these.

Copy link
Member

@stefanpenner stefanpenner Feb 28, 2018

Choose a reason for hiding this comment

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

static generate(): Frame {
static generate(root?: Element): Frame {
if (root && document.documentElement.contains(root)) {
let rootClientRect = getBoundingClientRect(root);
Copy link
Contributor

Choose a reason for hiding this comment

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

Can this be a const?

Copy link
Member Author

Choose a reason for hiding this comment

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

Confirmed

) {}
static generate(): Frame {
static generate(root?: Element): Frame {
if (root && document.documentElement.contains(root)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

In the past, we have used the window proxy for any direct touching of the document or the window, so that things don’t break in node land. Is this a safe direct document reference?

Copy link
Member Author

Choose a reason for hiding this comment

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

Nice catch. Besides the private conditionals within the window proxy for hasDomSetup and hasDOM we aren't currently (to my knowledge) checking for DOM before leveraging the document. We should definitely think about exporting a document proxy or even just a document interface to check for DOM etc.

windowSetScrollMeta();
}, throttleDelay);
// Only invalidate window isDirty on scroll and resize
function eventThrottle(eventType?: string, root?: Element) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Does consolidating into one throttle function buy us anything if we are still switching on each case?

Copy link
Member Author

Choose a reason for hiding this comment

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

Nothing major. One less function declaration in a succinct easier to read package.

Copy link
Member

Choose a reason for hiding this comment

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

I would strongly recommend using 2 discrete methods.

Rationale (for 2 functions)

  • more concise (less bytes)
  • less prone to error (if an invalid event type is used, we get a useful error)
  • remove unneeded wrapper function
  • we should defer machinery for extensibility until not having that machinery is causes grief.

Copy link
Member

Choose a reason for hiding this comment

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

also the second any of root appears to be never used, we can likely drop.

Copy link
Member

Choose a reason for hiding this comment

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

If a throttle utility is to be used, we should do so to avoid extra bytes. I believe something like the following could be used:

function throttle(cb) {
  let cookie;
  return () => {
    clearTimeout(cookie);
    cookie = setTimeout(cb, throttleDelay)
    // anything else, maybe the dirty check thing.
  }
}

addEventListener('resize', throttle(windowSetDimensionsMeta))
addEventListener('scroll', throttle(windowSetScrollMeta));

@lynchbomb
Copy link
Member Author

lynchbomb commented Feb 23, 2018

@asakusuma

Does this change handle nested roots?

It does not. I think at first pass we should focus on MVP. Especially since we are back-porting root functionality and the ideal is that we roll a new release of Spaniel. In-which at that point we can include nested roots.

I assume this handles the scenario where some other code captures the window scroll event?

Nice catch actually. It does not. I'll include a fallback for the window and root scroll events to leverage page offset and clientRect deltas respectively. Since we don't want to check clientRect per frame as the primary identifier for isDirty validation when stale; as this is the performance anti-pattern we are trying to remove.

if (customEngine) {
this.engine = customEngine;
} else {
this.engine = getGlobalEngine();
}

if (root) {
Copy link
Member

Choose a reason for hiding this comment

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

I think we can drop this guard, setting root regardless will ensure the schedular will have the same number of slots between different instances. In addition, we do use this.root, and thus is may as well be set on at this level, rather then falling back down the prototype chain.

@@ -43,19 +47,35 @@ let W: WindowProxy = {
getScrollLeft: nop,
getHeight: nop,
getWidth: nop,
rootsList: [],
onWindowIsDirtyListeners: [],
Copy link
Member

Choose a reason for hiding this comment

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

This data-structure seems better suited to be a Map then an Array.

onWindowIsDirtyListeners: [],
rAF: hasRAF ? window.requestAnimationFrame.bind(window) : (callback: Function) => { callback(); },
bindRootScrollEvent: (root: Element) => { root.addEventListener('scroll', () => W.callIsDirtyListeners(), false); },
Copy link
Member

Choose a reason for hiding this comment

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

what disconnects these listeners from custom roots?

onWindowIsDirtyListeners: [],
rAF: hasRAF ? window.requestAnimationFrame.bind(window) : (callback: Function) => { callback(); },
bindRootScrollEvent: (root: Element) => { root.addEventListener('scroll', () => W.callIsDirtyListeners(), false); },
callIsDirtyListeners() {
if (this.onWindowIsDirtyListeners.length <= 0) { return; }
Copy link
Member

Choose a reason for hiding this comment

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

This guard seems redundant, as forEach wont iterate if the list is empty. We should likely only add more code (for perf) if it we have seen a good reason to do so.

meta: {
width: 0,
height: 0,
scrollTop: 0,
scrollLeft: 0
},
__destroy__() {
disconnectIsDirtyListener(id: string) {
this.onWindowIsDirtyListeners = this.onWindowIsDirtyListeners.filter((obj: any) => {
Copy link
Member

Choose a reason for hiding this comment

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

if this becomes a map, this would become: this.onWindowIsDirtyListeners.delete(id)

Copy link
Member

Choose a reason for hiding this comment

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

if we do the above VERSION + instance._lastVersion stuff, this can be removed entirely.

if (root) {
if (!W.rootsList.includes(root)) {
W.rootsList.push(root);
W.bindRootScrollEvent(root);
Copy link
Member

Choose a reason for hiding this comment

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

what unbinds this scroll event

@@ -88,9 +91,17 @@ export class Watcher {
});
}

if (root) {
if (!W.rootsList.includes(root)) {
W.rootsList.push(root);
Copy link
Member

Choose a reason for hiding this comment

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

what removes from this list

src/utils.ts Outdated
try {
return element.getBoundingClientRect();
return element.getBoundingClientRect() as SpanielClientRectInterface;
} catch (e) {
if (typeof e === 'object' && e !== null && e.description.split(' ')[0] === 'Unspecified' && (e.number & 0xFFFF) === 16389) {
Copy link
Member

Choose a reason for hiding this comment

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

oh IE, you so cray cray

Copy link
Member

Choose a reason for hiding this comment

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

would it be safe to drop e.description.split(' ')[0] === 'Unspecified' && ?

src/watcher.ts Outdated
@@ -87,10 +90,18 @@ export class Watcher {
});
}

if (root) {
if (!W.rootsList.includes(root)) {
W.rootsList.push(root);
Copy link
Member

Choose a reason for hiding this comment

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

what removes from this list?

src/watcher.ts Outdated
if (root) {
if (!W.rootsList.includes(root)) {
W.rootsList.push(root);
W.bindRootScrollEvent(root);
Copy link
Member

Choose a reason for hiding this comment

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

what unbinds this event?

) {}
static generate(): Frame {
static generate(root?: Element): Frame {
if (root && document.documentElement.contains(root)) {
Copy link
Member

Choose a reason for hiding this comment

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

This guard, and the guard contained with getBoundingClientRect may be redundant when used together. As if the getBoundingClientRect fails, the various unknown attributes become 0,0,0,0 already right?

@lynchbomb
Copy link
Member Author

@stefanpenner and I spoke at length offline yesterday, about a hypothetical alternative to the reviewed comments and PR implementation above. In-which the hypothesis will need to be tested. If proven to be a viable alternative. The release of these changes must be behind a minor release + flag. This includes but not limited to:

  • Removing the window and root listeners and instead having consumers of Spaniel provide a propagated "virtual event" on state change. For example scroll and resize events as well as state mutations on reads/writes. The implementation of this will be more or less a propagated isDirty boolean, passed directly to the Spaniel#Watcher instance. Spaniel will hook into this "virtual event" to invalidate the cached getBoundingClientRect of the window, root and/or ElementScheduler watched Element.
  • Since root is now always passed (either as the default window or root element) safety checks can be removed as well as conditionals for isRoot.

The above implementation and API change would also set us up for major simplifications. Which would be included within a future v4 major release. Such as:

@lynchbomb
Copy link
Member Author

lynchbomb commented Mar 22, 2018

@stefanpenner FWIW here is the PR spike for Ember-Spaniel implementation of this PR. Most importantly onInViewportOnce() on a custom root as well as revalidate() for forced cached validation on getBoundingClientRect of the root and scheduler as well as window meta.

@lynchbomb
Copy link
Member Author

lynchbomb commented Mar 28, 2018

I chatted with @krisselden offline regarding the review of this PR. The primary feedback was when a custom root element is being watched, we should bind a scroll event within Spaniel on that custom root element to trigger cached state validation (ie the first iteration of this PR). That the common case would always require a scroll event on the custom root; keeping the API as dead simple as possible. Additionally (4/19/18):

  • Leveraging the Engine read/write within the WindowProxy when updating metadata.
  • Modify the WindowProxy to follow the same state invalidation pattern as the scheduler.
  • forceStateValidation should only check version NOT read/cache window metadata. Possibly rename to invalidateState.
  • If possible don't entangle the root and elements within ElementScheduler.
  • If possible discourage consumers from leveraging forceStateValidation everywhere

Additionally and outside the scope of this PR about the idea of Backburner implementing a measure queue, in-which would be highly leveragable with Spaniel.

@lynchbomb lynchbomb force-pushed the lynchbomb branch 2 times, most recently from 9959766 to b49d3b2 Compare April 23, 2018 16:58
@lynchbomb lynchbomb force-pushed the lynchbomb branch 2 times, most recently from 992951c to 1387e54 Compare May 2, 2018 17:47
}
};

export function validateState() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

name is confusing, I expected invalidate() or incrementVersion() or makeStateDirty()

version: number;
lastVersion: number;
updateMeta: Function;
isDirty: boolean;
Copy link
Collaborator

Choose a reason for hiding this comment

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

why have an isDirty + version + lastVersion?

Copy link
Member Author

Choose a reason for hiding this comment

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

isDirty is doing the validation check from version to lastVersion.

Copy link
Member Author

@lynchbomb lynchbomb May 2, 2018

Choose a reason for hiding this comment

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

For this PR I am not going to address this. However we will table this and include in Spaniel 4.0.0:

  • Cached version state on the Window Proxy will be moved to have its own interface with a version.
  • One flat object with the prefix last that pertain to the lastVersion ie lastWidth,
    lastTop etc.

ALLOW_CACHED_SCHEDULER: true
});

root.addEventListener('scroll', () => spaniel.invalidate(), false);
Copy link
Collaborator

Choose a reason for hiding this comment

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

this doesn't look like it is removed if the observer is disposed

Copy link
Collaborator

Choose a reason for hiding this comment

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

I realize this is a test, but is this the pattern that is encouraged?

Copy link
Member Author

Choose a reason for hiding this comment

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

Just as the consuming app will need to handle binding the event they are also responsible for removing it. In Ember-Spaniel for example, we will provide a clear API to do both (on/off) within Embers lifecycle hooks.

@lynchbomb lynchbomb merged commit f79345b into v2-release May 3, 2018
@lynchbomb lynchbomb deleted the lynchbomb branch May 3, 2018 17:36
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.

4 participants