Skip to content

Conversation

@kevinpschaaf
Copy link
Member

Re-works the disconnection handling for first-party AsyncDirectives (until, asyncAppend, asyncReplace) to use a WeakMap to connect an awaited promise's resolver and the directive that is awaiting it, so that a disconnected directive (and all of the DOM it references via its part) is not prevented from being GC'ed until its awaited promise resolves.

This effectively means they will have the previous behavior of deferring their commit until they are re-connected, but with the benefit of not leaking their DOM while disconnected.

Follow-on to #2034.

@changeset-bot
Copy link

changeset-bot bot commented Aug 10, 2021

🦋 Changeset detected

Latest commit: 0b6386b

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

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 Aug 10, 2021

📊 Tachometer Benchmark Results

Summary

nop-update

  • lit-html-kitchen-sink: unsure 🔍 -2% - +6% (-0.86ms - +2.05ms)
    this-change vs tip-of-tree

render

  • lit-element-list: unsure 🔍 -3% - +3% (-3.37ms - +2.83ms)
    this-change vs tip-of-tree
  • lit-html-kitchen-sink: unsure 🔍 -2% - +5% (-0.98ms - +1.99ms)
    this-change vs tip-of-tree
  • lit-html-repeat: unsure 🔍 -8% - +3% (-1.16ms - +0.39ms)
    this-change vs tip-of-tree
  • lit-html-template-heavy: unsure 🔍 -6% - +1% (-4.56ms - +0.53ms)
    this-change vs tip-of-tree
  • reactive-element-list: unsure 🔍 -2% - +2% (-1.21ms - +1.67ms)
    this-change vs tip-of-tree

update

  • lit-element-list: unsure 🔍 -2% - +2% (-15.52ms - +15.62ms)
    this-change vs tip-of-tree
  • lit-html-kitchen-sink: unsure 🔍 -4% - +4% (-4.54ms - +4.65ms)
    this-change vs tip-of-tree
  • lit-html-repeat: unsure 🔍 -3% - +2% (-11.77ms - +10.03ms)
    this-change vs tip-of-tree
  • lit-html-template-heavy: unsure 🔍 -3% - +3% (-3.68ms - +3.67ms)
    this-change vs tip-of-tree
  • reactive-element-list: unsure 🔍 -1% - +2% (-7.63ms - +19.71ms)
    this-change vs tip-of-tree

update-reflect

  • lit-element-list: unsure 🔍 -2% - +2% (-14.15ms - +18.09ms)
    this-change vs tip-of-tree
  • reactive-element-list: unsure 🔍 -1% - +2% (-10.24ms - +20.94ms)
    this-change vs tip-of-tree

Results

lit-element-list

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
104.71ms - 109.27ms-unsure 🔍
-3% - +3%
-3.37ms - +2.83ms
faster ✔
23% - 28%
32.20ms - 41.62ms
tip-of-tree
tip-of-tree
105.15ms - 109.37msunsure 🔍
-3% - +3%
-2.83ms - +3.37ms
-faster ✔
23% - 28%
32.00ms - 41.27ms
previous-release
previous-release
139.77ms - 148.02msslower ❌
30% - 39%
32.20ms - 41.62ms
slower ❌
29% - 39%
32.00ms - 41.27ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
874.89ms - 896.48ms-unsure 🔍
-2% - +2%
-15.52ms - +15.62ms
faster ✔
7% - 10%
62.27ms - 98.25ms
tip-of-tree
tip-of-tree
874.42ms - 896.85msunsure 🔍
-2% - +2%
-15.62ms - +15.52ms
-faster ✔
7% - 10%
62.06ms - 98.56ms
previous-release
previous-release
951.55ms - 980.34msslower ❌
7% - 11%
62.27ms - 98.25ms
slower ❌
7% - 11%
62.06ms - 98.56ms
-

update-reflect

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
886.55ms - 908.15ms-unsure 🔍
-2% - +2%
-14.15ms - +18.09ms
faster ✔
4% - 8%
42.07ms - 74.28ms
tip-of-tree
tip-of-tree
883.41ms - 907.36msunsure 🔍
-2% - +2%
-18.09ms - +14.15ms
-faster ✔
5% - 8%
43.23ms - 77.06ms
previous-release
previous-release
943.58ms - 967.47msslower ❌
5% - 8%
42.07ms - 74.28ms
slower ❌
5% - 9%
43.23ms - 77.06ms
-
lit-html-kitchen-sink

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
42.87ms - 44.68ms-unsure 🔍
-2% - +5%
-0.98ms - +1.99ms
faster ✔
14% - 19%
7.29ms - 9.80ms
tip-of-tree
tip-of-tree
42.09ms - 44.44msunsure 🔍
-5% - +2%
-1.99ms - +0.98ms
-faster ✔
15% - 20%
7.59ms - 10.51ms
previous-release
previous-release
51.45ms - 53.19msslower ❌
16% - 23%
7.29ms - 9.80ms
slower ❌
17% - 25%
7.59ms - 10.51ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
107.53ms - 113.20ms-unsure 🔍
-4% - +4%
-4.54ms - +4.65ms
unsure 🔍
-6% - +1%
-6.84ms - +0.87ms
tip-of-tree
tip-of-tree
106.70ms - 113.92msunsure 🔍
-4% - +4%
-4.65ms - +4.54ms
-unsure 🔍
-7% - +1%
-7.50ms - +1.42ms
previous-release
previous-release
110.74ms - 115.96msunsure 🔍
-1% - +6%
-0.87ms - +6.84ms
unsure 🔍
-1% - +7%
-1.42ms - +7.50ms
-

nop-update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
36.95ms - 38.91ms-unsure 🔍
-2% - +6%
-0.86ms - +2.05ms
unsure 🔍
-2% - +6%
-0.74ms - +2.19ms
tip-of-tree
tip-of-tree
36.27ms - 38.41msunsure 🔍
-5% - +2%
-2.05ms - +0.86ms
-unsure 🔍
-4% - +4%
-1.39ms - +1.65ms
previous-release
previous-release
36.12ms - 38.29msunsure 🔍
-6% - +2%
-2.19ms - +0.74ms
unsure 🔍
-4% - +4%
-1.65ms - +1.39ms
-
lit-html-repeat

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
13.28ms - 14.13ms-unsure 🔍
-8% - +3%
-1.16ms - +0.39ms
faster ✔
12% - 19%
1.95ms - 3.20ms
tip-of-tree
tip-of-tree
13.44ms - 14.74msunsure 🔍
-3% - +9%
-0.39ms - +1.16ms
-faster ✔
9% - 18%
1.39ms - 2.99ms
previous-release
previous-release
15.82ms - 16.74msslower ❌
14% - 24%
1.95ms - 3.20ms
slower ❌
9% - 22%
1.39ms - 2.99ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
397.11ms - 411.88ms-unsure 🔍
-3% - +2%
-11.77ms - +10.03ms
faster ✔
27% - 31%
151.91ms - 178.39ms
tip-of-tree
tip-of-tree
397.35ms - 413.38msunsure 🔍
-2% - +3%
-10.03ms - +11.77ms
-faster ✔
27% - 31%
150.68ms - 177.88ms
previous-release
previous-release
558.66ms - 580.63msslower ❌
37% - 45%
151.91ms - 178.39ms
slower ❌
37% - 44%
150.68ms - 177.88ms
-
lit-html-template-heavy

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
67.94ms - 70.62ms-unsure 🔍
-6% - +1%
-4.56ms - +0.53ms
faster ✔
15% - 19%
12.05ms - 15.62ms
tip-of-tree
tip-of-tree
69.13ms - 73.46msunsure 🔍
-1% - +7%
-0.53ms - +4.56ms
-faster ✔
11% - 17%
9.36ms - 14.29ms
previous-release
previous-release
81.94ms - 84.29msslower ❌
17% - 23%
12.05ms - 15.62ms
slower ❌
13% - 20%
9.36ms - 14.29ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
137.66ms - 143.12ms-unsure 🔍
-3% - +3%
-3.68ms - +3.67ms
faster ✔
10% - 14%
15.33ms - 22.94ms
tip-of-tree
tip-of-tree
137.94ms - 142.85msunsure 🔍
-3% - +3%
-3.67ms - +3.68ms
-faster ✔
10% - 14%
15.52ms - 22.74ms
previous-release
previous-release
156.88ms - 162.18msslower ❌
11% - 17%
15.33ms - 22.94ms
slower ❌
11% - 16%
15.52ms - 22.74ms
-
reactive-element-list

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
72.54ms - 74.67ms-unsure 🔍
-2% - +2%
-1.21ms - +1.67ms
unsure 🔍
-0% - +3%
-0.30ms - +2.45ms
tip-of-tree
tip-of-tree
72.41ms - 74.34msunsure 🔍
-2% - +2%
-1.67ms - +1.21ms
-unsure 🔍
-1% - +3%
-0.45ms - +2.14ms
previous-release
previous-release
71.67ms - 73.39msunsure 🔍
-3% - +0%
-2.45ms - +0.30ms
unsure 🔍
-3% - +1%
-2.14ms - +0.45ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
837.60ms - 857.39ms-unsure 🔍
-1% - +2%
-7.63ms - +19.71ms
unsure 🔍
-0% - +3%
-2.28ms - +27.85ms
tip-of-tree
tip-of-tree
832.03ms - 850.89msunsure 🔍
-2% - +1%
-19.71ms - +7.63ms
-unsure 🔍
-1% - +3%
-8.02ms - +21.52ms
previous-release
previous-release
823.35ms - 846.07msunsure 🔍
-3% - +0%
-27.85ms - +2.28ms
unsure 🔍
-3% - +1%
-21.52ms - +8.02ms
-

update-reflect

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
902.90ms - 924.60ms-unsure 🔍
-1% - +2%
-10.24ms - +20.94ms
unsure 🔍
-1% - +2%
-11.63ms - +18.29ms
tip-of-tree
tip-of-tree
897.21ms - 919.59msunsure 🔍
-2% - +1%
-20.94ms - +10.24ms
-unsure 🔍
-2% - +1%
-17.24ms - +13.19ms
previous-release
previous-release
900.12ms - 920.73msunsure 🔍
-2% - +1%
-18.29ms - +11.63ms
unsure 🔍
-1% - +2%
-13.19ms - +17.24ms
-

tachometer-reporter-action v2 for Benchmarks

@kevinpschaaf kevinpschaaf changed the title Re-work async directives for better gc behavior [lit-html] Re-work async directives for better gc behavior Aug 10, 2021
const newPart = insertPart(part);
setChildPartValue(newPart, v);
i++;
// Override point for AsyncReplace to replace rather than append
Copy link
Member

Choose a reason for hiding this comment

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

The comments here are confusing since this is AsyncAppend.

Copy link
Member Author

Choose a reason for hiding this comment

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

I made AsyncAppend subclass AsyncReplace since it's mostly identical code. As you can see in AsyncAppend, the only differences are:

  • validatePartType: AsyncReplace can be used anywhere but AsyncAppend can only go in ChildPart position
  • update: AsyncAppend needs to save the __childPart since it inserts new parts into it
  • commitValue: AsyncAppend inserts new parts for each value, whereas AsyncReplace just calls setValue and overwrites the previous

Copy link
Member Author

Choose a reason for hiding this comment

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

Oic, I had the comments backward. Fixed.


// Override point for AsyncReplace, which can only be used in ChildParts
protected validatePartType(_partInfo: PartInfo) {
// AsyncReplace can be used in any part type
Copy link
Member

Choose a reason for hiding this comment

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

Why is there no check here?

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah, I had originally done AsyncReplace extends AsyncAppend which needed to loosen the part validation, but flipping it to AsyncAppend extends AsyncReplace lets AsyncAppend just do the stricter part validation in its constructor.

Removed.

}
: {}),
};
console.log(config);
Copy link
Member

Choose a reason for hiding this comment

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

Remove?

Copy link
Member Author

Choose a reason for hiding this comment

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

Removed

// Promise passed to until that will never resolve
const iterable = new TestAsyncIterable<string>();
iterables.push(iterable);
// Render the until into a `<span>` with a 10kb expando, to exaggerate
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// Render the until into a `<span>` with a 10kb expando, to exaggerate
// Render the directive into a `<span>` with a 10kb expando, to exaggerate

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

forceGC();
const heap = performance.memory.usedJSHeapSize;
for (let i = 0; i < 1000; i++) {
// Promise passed to until that will never resolve
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// Promise passed to until that will never resolve
// Promise passed to directive that will never resolve

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

forceGC();
const heap = performance.memory.usedJSHeapSize;
for (let i = 0; i < 1000; i++) {
// Promise passed to until that will never resolve
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// Promise passed to until that will never resolve
// Promise passed to directive that will never resolve

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

// Promise passed to until that will never resolve
const iterable = new TestAsyncIterable<string>();
iterables.push(iterable);
// Render the until into a `<span>` with a 10kb expando, to exaggerate
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// Render the until into a `<span>` with a 10kb expando, to exaggerate
// Render the directive into a `<span>` with a 10kb expando, to exaggerate

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

// get a reference back to the directive to perform the commit via the WeakMap
// rather than closing over the directive instance, which would hold the
// directive until the promise resolved.
const promiseToDirectiveMap: WeakMap<Promise<unknown>, AsyncReplaceDirective> =
Copy link
Member

Choose a reason for hiding this comment

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

What if the same iterable and thus promise is used in multiple directives? Does this need to be (can it be?) on the directive instance?

Copy link
Member Author

Choose a reason for hiding this comment

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

Man, that's a good point (more for until, since I think that iterables are more likely to return fresh promises for each next() call). I can see if the WeakMaps can live on the directive instance if we pull it off first and only close over the WeakMap. Lemme try that.

Copy link
Member Author

Choose a reason for hiding this comment

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

To address this, went ahead and refactored it into a DisconnectableAwaiter that wraps up the logic around indirecting the this ref to the directive, storing a value that resolves while disconnected, and flushing it back to the thenFn if resolved while disconnected.

Copy link
Member Author

Choose a reason for hiding this comment

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

Added tests for same promise/iterable passed to multiple directive instances as well.

promiseToDirectiveMap.set(nextPromise, this);
}
nextPromise.then((result) => {
const directive = promiseToDirectiveMap.get(nextPromise);
Copy link
Member

Choose a reason for hiding this comment

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

Here directive is always the same as this isn't it? Does this mean we don't need to actually store the directive value and only need to know if this promise is mapped?

Copy link
Member Author

Choose a reason for hiding this comment

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

The whole thing is not closing over this in the thenFn. Going through the WeakMap with the promise as the key is what breaks the dependency.

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 a lot of boilerplate that each of the directives are having to implement. Ideally, we'd just be using WeakRefs to weakly hold onto the directive this, and everything would be handled automatically. But not every browser will support that.

The good news is that directives support disconnected and reconnected, so we can implement a pseudo WeakRef with manual tracking very easily (the only difficult thing about implementing a WeakRef is making it automatic).

WeakRefWrapper
const backing = new WeakMap<WeakRefWrapper, unknown>();
class WeakRefWrapper {
  _weakref = null;
  
  constructor(obj: unknown) {
    if (typeof WeakRef === 'function') {
      this._weakref = new WeakRef(obj);
    } else {
      backing.set(this, obj);
    }
  }
  
  deref() {
    if (this._weakref) return this._weakref.deref();
    return backing.get(this);
  }
  
  // reconnected would call `weakthis.aquire(this)` to reestablish the weakref.
  acquire(obj) {
    if (!this._weakref) {
      // This should really be the same object as original, but we can't
      // verify that without strongly holding onto the original...
      backing.set(this, obj);
    }
  }
  
  // disconnected would call `weakthis.release()` to break the weakref.
  release() {
    if (!this._weakref) {
      backing.delete(this);
    }
  }
}

Then, we don't need this manual promise->directive tracking anymore. Most of the code can remain unchanged. The only difference is that we now refer to a weakthis instead of this. Combined with the asyncForEach helper, that means the majority of the code can stay the same while still preventing the memory leak.

I have a really basic example in https://output.jsbin.com/qumamir/quiet

// rendering a template for each item.
if (mapper !== undefined) {
v = mapper(v, i);
private __awaitNextValue(nextPromise = this.__iterator!.next()) {
Copy link
Member

Choose a reason for hiding this comment

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

This is never called with the argument so let's remove it.

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

this.__mapper = mapper;
this.__iterable = iterable;
this.index = 0;
// Note, we use the iterator protocol manually (rather than for/await/of) so
Copy link
Member

Choose a reason for hiding this comment

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

This is pretty tricky. It'd be really nice to have a DisconnectablePromise directive-helper to make this more generally available.

Copy link
Contributor

@jridgewell jridgewell Aug 14, 2021

Choose a reason for hiding this comment

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

With a helper, you can actually continue using for await. The issue is that we're inside the same method where we just referenced this. If we defer to something like an asyncForEach, we've broken the inherent memory leak. We'll still need to indirect to get the directve value, but that's much simpler than writing manual iteration:

async function asyncForEach(iterable, cb) {
  for await (const v of iterable) {
    if (await cb(v) === false) break;
  }
}

Adding in === false allows us to break the iteration, if needed for any reason. And the await cb(v) would allow us to await a pause action, much like the current code:

AsyncPause
class AsyncPause {
  _pause = null;
  _resolve = null;

  get() {
    return this._pause;
  }

  pause() {
    this._pause ||= new Promise(resolve => {
      this._resolve = resolve;
    });
  }
  
  resume() {
    this._resolve?.();
    this._pause = null;
    this._resolve = null;
  }
}
class AsyncDirective {
  _pauser = new AsyncPause();

  update(iterable) {
    const pauser = this._pauser;
    asyncForEach(iterable, async (value) => {
      if (pauser.get()) await pauser.get();

      // indirect to get directive here.

      if (directive._value !== iterable) return false;

      // render here
    });
  }
}

return !isPrimitive(x) && typeof (x as {then?: unknown}).then === 'function';
};
// Effectively infinity, but a SMI.
const _infinity = 0x7fffffff;
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: I actually got this incorrect. SMIs in v8 can only be 30bits, with 1 bit for the SMI tag and 1 bit for the sign. https://v8.dev/blog/react-cliff

Suggested change
const _infinity = 0x7fffffff;
const _infinity = 0x3fffffff;

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

promiseToDirectiveMap.set(nextPromise, this);
}
nextPromise.then((result) => {
const directive = promiseToDirectiveMap.get(nextPromise);
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 a lot of boilerplate that each of the directives are having to implement. Ideally, we'd just be using WeakRefs to weakly hold onto the directive this, and everything would be handled automatically. But not every browser will support that.

The good news is that directives support disconnected and reconnected, so we can implement a pseudo WeakRef with manual tracking very easily (the only difficult thing about implementing a WeakRef is making it automatic).

WeakRefWrapper
const backing = new WeakMap<WeakRefWrapper, unknown>();
class WeakRefWrapper {
  _weakref = null;
  
  constructor(obj: unknown) {
    if (typeof WeakRef === 'function') {
      this._weakref = new WeakRef(obj);
    } else {
      backing.set(this, obj);
    }
  }
  
  deref() {
    if (this._weakref) return this._weakref.deref();
    return backing.get(this);
  }
  
  // reconnected would call `weakthis.aquire(this)` to reestablish the weakref.
  acquire(obj) {
    if (!this._weakref) {
      // This should really be the same object as original, but we can't
      // verify that without strongly holding onto the original...
      backing.set(this, obj);
    }
  }
  
  // disconnected would call `weakthis.release()` to break the weakref.
  release() {
    if (!this._weakref) {
      backing.delete(this);
    }
  }
}

Then, we don't need this manual promise->directive tracking anymore. Most of the code can remain unchanged. The only difference is that we now refer to a weakthis instead of this. Combined with the asyncForEach helper, that means the majority of the code can stay the same while still preventing the memory leak.

I have a really basic example in https://output.jsbin.com/qumamir/quiet

this.__mapper = mapper;
this.__iterable = iterable;
this.index = 0;
// Note, we use the iterator protocol manually (rather than for/await/of) so
Copy link
Contributor

@jridgewell jridgewell Aug 14, 2021

Choose a reason for hiding this comment

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

With a helper, you can actually continue using for await. The issue is that we're inside the same method where we just referenced this. If we defer to something like an asyncForEach, we've broken the inherent memory leak. We'll still need to indirect to get the directve value, but that's much simpler than writing manual iteration:

async function asyncForEach(iterable, cb) {
  for await (const v of iterable) {
    if (await cb(v) === false) break;
  }
}

Adding in === false allows us to break the iteration, if needed for any reason. And the await cb(v) would allow us to await a pause action, much like the current code:

AsyncPause
class AsyncPause {
  _pause = null;
  _resolve = null;

  get() {
    return this._pause;
  }

  pause() {
    this._pause ||= new Promise(resolve => {
      this._resolve = resolve;
    });
  }
  
  resume() {
    this._resolve?.();
    this._pause = null;
    this._resolve = null;
  }
}
class AsyncDirective {
  _pauser = new AsyncPause();

  update(iterable) {
    const pauser = this._pauser;
    asyncForEach(iterable, async (value) => {
      if (pauser.get()) await pauser.get();

      // indirect to get directive here.

      if (directive._value !== iterable) return false;

      // render here
    });
  }
}

@kevinpschaaf
Copy link
Member Author

@jridgewell Cool, thanks for the inspiration here. I was actually working on factoring the bookkeeping out, but I think we decided to do that in a follow-on PR, since it can be an implementation detail change after we get the core behavior working.

Base automatically changed from revert-disconnected-updates to main August 17, 2021 05:25
Allows the directives (and the DOM they are attached to) to be gc'ed when disconnected before awaited promises/iterables resolve
continue;
}

// We have a Promise that we haven't seen before, so priorities may have
Copy link
Contributor

Choose a reason for hiding this comment

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

We have access to previousValues here, so we could preemptively clean up the __pendingValueToAwaiterMap to prevent it from getting too large (map performance degrades with its size).

Really, I would just store the pending awaiters in an array.

}
this._thenFn = thenFn;
promise.then((result: ResultType) => {
const weakDirective = directiveMap.get(this) as DirectiveType;
Copy link
Contributor

Choose a reason for hiding this comment

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

Because you're performing disconnect() and reconnect() manually, there shouldn't be a need to indirect through a WeakMap. Just storing this directive as an own property will be considerably faster. If the DisconnectableAwaiter gets collected, then own property will go with it the same as the weak key. And if it doesn't get collected, then both the property and key would stick around.

Copy link
Member Author

Choose a reason for hiding this comment

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

Doh, right.

// Note, we use the iterator protocol manually (rather than for/await/of),
// which lets us use the `DisconnectableAwaiter` helper to disconnect &
// reconnect awaiting the next value from the iterable
this.__iterator = iterable[Symbol.asyncIterator]();
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: This is a slight change it behavior. for await will consume a sync iterator if the async iterator isn't define.

Copy link
Member Author

Choose a reason for hiding this comment

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

Reverted to for await

this.__iterator!.next(),
this,
(directive, result) => directive.__commitResult(result)
);
Copy link
Contributor

Choose a reason for hiding this comment

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

This still seems to be a lot more complex than the alternative. Every loop requires a new object, 2 promises, and 2/3 closures.

The AsyncPause and WeakRefWrapper (could just be PseudoWeakRef without the fallback to real WeakRef) requires just 2 objects and a closure (all of which can be reused for every iteration) and internal promise allocation.

Copy link
Member Author

Choose a reason for hiding this comment

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

Ok, I think that makes sense. Did another update to go with that approach. @justinfagnani @sorvell also PTAL


// Separate function to avoid an iffe in update; update can't be async
// because its return value must be `noChange`
private async __iterate(mapper?: Mapper<unknown>) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: No longer async. If you want to return a promise still, we could return forAwaitOf(…) instead.

Suggested change
private async __iterate(mapper?: Mapper<unknown>) {
private __iterate(mapper?: Mapper<unknown>) {

Copy link
Member Author

Choose a reason for hiding this comment

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

Good catch, it can be inlined back into update now.
Done.

this._lastRenderedIndex = index;
this.setValue(resolvedValue);
// Note, the callback avoids closing over `this` so that the directive
// can be gc'ed before the promise resolves
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: do we want to copy this comment to the AsyncReplaceDirective, too?

Copy link
Member Author

Choose a reason for hiding this comment

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

+1 Done

@@ -0,0 +1,67 @@
/**
* @license
* Copyright 2017 Google LLC
Copy link
Collaborator

Choose a reason for hiding this comment

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

2021

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

const heap = performance.memory.usedJSHeapSize;
for (let i = 0; i < 1000; i++) {
// Iterable passed to asyncAppend that will never yield
const iterable = new TestAsyncIterable<string>();
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why do we create a new iterable per loop? Can we test with one iterable that never receives a value?

Copy link
Member Author

@kevinpschaaf kevinpschaaf Aug 19, 2021

Choose a reason for hiding this comment

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

The iterable is passed into the directive and then the entire part is cleared in the same loop. Each loop makes a new span (with expando) <-- part <-- directive instance (with iterable) <-- awaited closure which will leak if the closure strongly references the directive instance.

/**
* When paused, returns a promise to be awaited; when unpaused, returns
* undefined. Note that in the microtask between the pauser being resumed
* an an await of this promise resolving, the pauser could be paused again,
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
* an an await of this promise resolving, the pauser could be paused again,
* and an await of this promise resolving, the pauser could be paused again,

* undefined. Note that in the microtask between the pauser being resumed
* an an await of this promise resolving, the pauser could be paused again,
* hence callers should check the promise in a loop when awaiting.
* @returns A promise to be awaited when paused or undefined
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
* @returns A promise to be awaited when paused or undefined
* @returns A promise to be awaited when paused, or undefined

forAwaitOf(value, async (v: unknown) => {
// The while loop here handles the case that the connection state
// thrashes, causing the pauser to resume and then get re-paused
while (pauser.get()) {
Copy link
Member

Choose a reason for hiding this comment

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

Can this just be while (await pauser.get()) {}?

Copy link
Member

Choose a reason for hiding this comment

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

Or can this be collapsed into the class? See comment above.

Copy link
Member Author

Choose a reason for hiding this comment

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

while (await pauser.get()) adds a microtask to every value when connected

/**
* A helper to pause and resume waiting on a condition in an async function
*/
export class Pauser {
Copy link
Member

Choose a reason for hiding this comment

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

Perhaps bikeshedding, but it seems slightly better if this is called a Player and has play and pause methods. It could then have an isPaused() method which returns a promise that's resolved when play is called. Could that promise also just encapsulate the for loop that's currently used with get? The API seems less awkward that way.

Copy link
Member

Choose a reason for hiding this comment

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

Or maybe Runner?

Copy link
Member Author

Choose a reason for hiding this comment

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

I started to implement it that way, but the final falsey check on pauser.get() must be synchronous to resuming, and you can't encapsulate that in an async function because the outside await adds a microtask between which pauser.get() might return truthy again and thus would be wrong.

while (pauser.get()) {
await pauser.get();
}
const _this = weakThis.deref();
Copy link
Member

Choose a reason for hiding this comment

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

Just for ultra clarity, suggest adding a comment here too that this avoids closing over this since the value is pulled out of the weakThis and that it's only there if the part is connected.

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

// when devtools is closed. Waiting for rAF might be more reliable, but
// this waits the minimum that seems reliable now.
await Promise.resolve();
await Promise.resolve();
Copy link
Member

Choose a reason for hiding this comment

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

Why was this added?

Copy link
Member

@sorvell sorvell left a comment

Choose a reason for hiding this comment

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

LGTM, consider my suggestions optional.

@kevinpschaaf kevinpschaaf merged commit 662209c into main Aug 19, 2021
@kevinpschaaf kevinpschaaf deleted the async-directive-gc branch August 19, 2021 17:00
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.

4 participants