diff --git a/addon/index.js b/addon/index.js index 6fc1505..a3b2f87 100644 --- a/addon/index.js +++ b/addon/index.js @@ -34,6 +34,7 @@ export function ref(name, fn) { const value = bucketFor(this).get(name); return maybeReturnCreated(value, createdValues, fn, this); }, + configurable: true, }; }; } @@ -49,6 +50,7 @@ export function globalRef(name, fn) { const value = bucketFor(getOwner(this) || resolveGlobalRef()).get(name); return maybeReturnCreated(value, createdValues, fn, this); }, + configurable: true, }; }; } @@ -64,6 +66,7 @@ export function trackedRef(name, fn) { const value = bucketFor(this).getTracked(name); return maybeReturnCreated(value, createdValues, fn, this); }, + configurable: true, }; }; } @@ -81,6 +84,7 @@ export function trackedGlobalRef(name, fn) { ).getTracked(name); return maybeReturnCreated(value, createdValues, fn, this); }, + configurable: true, }; }; } diff --git a/addon/modifiers/create-ref.js b/addon/modifiers/create-ref.js index be028f7..92f33cd 100644 --- a/addon/modifiers/create-ref.js +++ b/addon/modifiers/create-ref.js @@ -22,9 +22,14 @@ export default class RefModifier extends Modifier { setGlobalRef(getOwner(this)); registerDestructor(this, () => { + const element = this._element; this.cleanMutationObservers(); this.cleanResizeObservers(); - getNodeDestructors(this._element).forEach((cb) => cb()); + getNodeDestructors(element).forEach((cb) => cb()); + if (element === bucketFor(this._ctx).get(this._key)) { + bucketFor(this._ctx).add(this._key, null); + } + delete this._element; }); } // to minimise overhead, user should be specific about diff --git a/addon/utils/ref.js b/addon/utils/ref.js index 6087fa1..7b0058e 100644 --- a/addon/utils/ref.js +++ b/addon/utils/ref.js @@ -1,3 +1,6 @@ +// @ts-check +/*eslint no-undef: "warn"*/ + import { registerDestructor, isDestroying, @@ -5,12 +8,67 @@ import { } from '@ember/destroyable'; import { tracked } from '@glimmer/tracking'; +/** + * @type {object | null} + */ let lastGlobalRef = null; +/** + * @type {WeakMap>} + */ const buckets = new WeakMap(); +/** + * @type {WeakMap void>>} + */ const nodeDestructors = new WeakMap(); +const hasWeakRef = typeof WeakRef !== 'undefined'; + +function fromWeakRefIfSupported(node) { + if (hasWeakRef && node instanceof WeakRef) { + return node.deref() ?? null; + } + return node; +} + +/** + * + * @param {null | undefined | WeeakRef | HTMLElement } node + * @returns + */ +function toWeakRefIfSupported(node) { + if (node === null || node === undefined) { + return null; + } + if (node instanceof WeakRef) { + return node; + } + if (hasWeakRef) { + return new WeakRef(node); + } + return node; +} + class FieldCell { - @tracked value = null; + /** + /** + * @type {null | (WeakRef | HTMLElement)} + */ + @tracked + _element = null; + get value() { + if (this._element) { + return fromWeakRefIfSupported(this._element); + } else { + return null; + } + } + set value(element) { + if (element) { + this._element = toWeakRefIfSupported(element); + } else { + this._element = null; + } + } } export function setGlobalRef(value) { @@ -27,35 +85,62 @@ export function resolveGlobalRef() { function createBucket() { return { + /** + * @type { Record } + */ bucket: {}, + /** + * @type { Record } + */ keys: {}, + /** + * @param {string} key + */ createTrackedCell(key) { if (!(key in this.keys)) { this.keys[key] = new FieldCell(); } }, + /** + * @param {string} name + * @returns { HTMLElement | null } + */ get(name) { this.createTrackedCell(name); - return this.bucket[name] || null; + return fromWeakRefIfSupported(this.bucket[name]) || null; }, + /** + * @param {string} name + */ dirtyTrackedCell(name) { this.createTrackedCell(name); const val = this.keys[name].value; this.keys[name].value = val; }, + /** + * @param {string} name + */ getTracked(name) { this.createTrackedCell(name); return this.keys[name].value; }, + /** + * @param {string} name + * @param {HTMLElement} value + */ add(name, value) { this.createTrackedCell(name); this.keys[name].value = value; - this.bucket[name] = value; + this.bucket[name] = toWeakRefIfSupported(value); if (!(name in this.notificationsFor)) { this.notificationsFor[name] = []; } this.notificationsFor[name].forEach((fn) => fn()); }, + /** + * @param {string} name + * @param {() => void} fn + */ addNotificationFor(name, fn) { if (!(name in this.notificationsFor)) { this.notificationsFor[name] = []; @@ -67,19 +152,37 @@ function createBucket() { ); }; }, + /** + * @type { Record void>> } + */ notificationsFor: {}, }; } +/** + * + * @param {HTMLElement} node + * @returns {Array<() => void>} + */ export function getNodeDestructors(node) { return nodeDestructors.get(node) || []; } + +/** + * @param {HTMLElement} node + * @param {() => void} cb + */ export function registerNodeDestructor(node, cb) { if (!nodeDestructors.has(node)) { nodeDestructors.set(node, []); } - nodeDestructors.get(node).push(cb); + nodeDestructors.get(node)?.push(cb); } +/** + * + * @param {HTMLElement} node + * @param {()=> void} cb + */ export function unregisterNodeDestructor(node, cb) { const destructors = nodeDestructors.get(node) || []; nodeDestructors.set( @@ -87,6 +190,11 @@ export function unregisterNodeDestructor(node, cb) { destructors.filter((el) => el !== cb) ); } +/** + * + * @param {object} rawCtx + * @returns {ReturnType | undefined} + */ export function bucketFor(rawCtx) { const ctx = rawCtx; if (!buckets.has(ctx)) { @@ -104,7 +212,14 @@ export function bucketFor(rawCtx) { } return buckets.get(ctx); } +/** + * + * @param {string} name + * @param {object} bucketRef + * @param {()=>void} cb + * @returns + */ export function watchFor(name, bucketRef, cb) { const bucket = bucketFor(bucketRef); - return bucket.addNotificationFor(name, cb); + return bucket?.addNotificationFor(name, cb); } diff --git a/config/ember-try.js b/config/ember-try.js index 1fb3d80..cdad7ec 100644 --- a/config/ember-try.js +++ b/config/ember-try.js @@ -23,6 +23,9 @@ module.exports = async function () { 'ember-auto-import': '~2.4.0', webpack: '~5.67.0', }, + dependencies: { + '@ember/string': '3.1.1', + }, }, }, { @@ -33,6 +36,9 @@ module.exports = async function () { 'ember-auto-import': '~2.4.0', webpack: '~5.67.0', }, + dependencies: { + '@ember/string': '3.1.1', + }, }, }, { @@ -43,6 +49,9 @@ module.exports = async function () { 'ember-auto-import': '~2.4.0', webpack: '~5.67.0', }, + dependencies: { + '@ember/string': '3.1.1', + }, }, }, { diff --git a/tests/dummy/app/components/ref-gc.hbs b/tests/dummy/app/components/ref-gc.hbs new file mode 100644 index 0000000..8d71c54 --- /dev/null +++ b/tests/dummy/app/components/ref-gc.hbs @@ -0,0 +1,10 @@ +
+ + +
+ +{{#each this.items as |item|}} +
+ {{item}} +
+{{/each}} \ No newline at end of file diff --git a/tests/dummy/app/components/ref-gc.js b/tests/dummy/app/components/ref-gc.js new file mode 100644 index 0000000..e3c5b41 --- /dev/null +++ b/tests/dummy/app/components/ref-gc.js @@ -0,0 +1,34 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { ref } from 'ember-ref-bucket'; + +export default class Test extends Component { + constructor() { + super(...arguments); + this.args.sendContext(this); + } + /** + * @type {number[]} + */ + @tracked items = []; + + @action addItems() { + this.items = [ + ...this.items, + ...Array(100) + .fill(0) + .map((_, i) => i), + ]; + this.items.forEach((item) => { + Object.defineProperty(this, `item-${item}`, ref(`item-${item}`)(this)); + }); + } + + @action clear() { + // this.items.forEach((item) => { + // delete this[`item-${item}`]; + // }); + this.items = []; + } +} diff --git a/tests/integration/components/ref-gc-test.js b/tests/integration/components/ref-gc-test.js new file mode 100644 index 0000000..446687b --- /dev/null +++ b/tests/integration/components/ref-gc-test.js @@ -0,0 +1,33 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, click } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | ref-gc', function (hooks) { + setupRenderingTest(hooks); + + // TODO: Replace this with your real tests. + test('it renders', async function (assert) { + let context = null; + this.sendContext = (ctx) => { + context = ctx; + }; + + await render(hbs``); + + await click('.add-items'); + + assert.strictEqual(typeof context[`item-${0}`], 'object'); + assert.strictEqual(context[`item-${0}`].tagName, 'DIV'); + + await click('.clear'); + + assert.strictEqual(typeof context[`item-${0}`], 'object'); + assert.strictEqual(String(context[`item-${0}`]), 'null'); + + await click('.add-items'); + assert.strictEqual(context[`item-${0}`].tagName, 'DIV'); + await click('.clear'); + assert.strictEqual(String(context[`item-${0}`]), 'null'); + }); +});