From c74d70040b2ff42c4620a3c0ae446731b9b5da36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sat, 25 Jul 2026 19:06:18 +0200 Subject: [PATCH] Guard dashboard against contributors that fail to load from IPFS --- app/components/user-avatar/component.js | 13 ++++++- app/services/kredits.js | 39 ++++++++++++++++++- .../components/user-avatar/component-test.js | 16 ++++++++ tests/unit/services/kredits-test.js | 31 +++++++++++++++ 4 files changed, 95 insertions(+), 4 deletions(-) diff --git a/app/components/user-avatar/component.js b/app/components/user-avatar/component.js index 46e81f2..14b3be0 100644 --- a/app/components/user-avatar/component.js +++ b/app/components/user-avatar/component.js @@ -19,8 +19,17 @@ export default Component.extend({ src: alias('avatarURL'), title: alias('contributor.name'), - avatarURL: computed('contributor.github_uid', 'size', function() { - const github_uid = this.contributor.github_uid; + // Re-compute whenever the contributor reference itself changes (covers a + // previously-undefined contributor being lazily loaded later), as well as + // the github_uid property when one is present. + avatarURL: computed('contributor', 'contributor.github_uid', 'size', function() { + const contributor = this.contributor; + + if (!contributor) { + return ''; + } + + const github_uid = contributor.github_uid; if (github_uid) { return `https://avatars2.githubusercontent.com/u/${github_uid}?v=3&s=${SIZES[this.size]}`; diff --git a/app/services/kredits.js b/app/services/kredits.js index 898c8c0..641af86 100644 --- a/app/services/kredits.js +++ b/app/services/kredits.js @@ -36,6 +36,11 @@ export default Service.extend({ reimbursements: null, githubAccessToken: null, + // IDs of contributors currently being lazy-fetched (see ensureContributorLoaded), + // used to avoid spawning duplicate concurrent requests when many contributions + // for the same missing contributor are loaded in a tight loop. + contributorsFetching: null, + currentUserIsContributor: notEmpty('currentUser'), currentUserIsCore: alias('currentUser.isCore'), hasAccounts: notEmpty('currentUserAccounts'), @@ -56,6 +61,7 @@ export default Service.extend({ this.set('contributors', []); this.set('contributions', []); this.set('reimbursements', []); + this.set('contributorsFetching', new Set()); if (window.ethereum) { window.ethereum.on('chainChanged', this.handleUserChainChanged); @@ -187,10 +193,17 @@ export default Service.extend({ kreditsByContributor: computed('contributionsUnconfirmed.@each.vetoed', 'contributors.[]', function() { const contributionsUnconfirmed = this.contributionsUnconfirmed.filterBy('vetoed', false); const contributionsGrouped = groupBy(contributionsUnconfirmed, 'contributorId'); - const contributorsWithUnconfirmed = contributionsGrouped.map(c => c.value); + + // Drop groups whose contributor isn't loaded yet (e.g. an IPFS profile + // failed to deserialize). They'll be re-included once the contributor + // gets lazily fetched via loadContributionFromData. + const contributionsGroupedKnown = contributionsGrouped.filter(c => { + return this.contributors.findBy('id', c.value); + }); + const contributorsWithUnconfirmed = contributionsGroupedKnown.map(c => c.value); const contributorsWithOnlyConfirmed = this.contributors.reject(c => contributorsWithUnconfirmed.includes(c.id)) - const kreditsByContributor = contributionsGrouped.map(c => { + const kreditsByContributor = contributionsGroupedKnown.map(c => { const amountUnconfirmed = c.items.mapBy('amount').reduce((a, b) => a + b); const contributor = this.contributors.findBy('id', c.value); @@ -302,6 +315,26 @@ export default Service.extend({ .then(data => this.loadContributorFromData(data)) }, + // When a contribution/reimbursement references a contributor that isn't in + // the local collection yet (e.g. its IPFS profile failed to deserialize on a + // previous attempt, or it was added out-of-band), lazily fetch it in the + // background. Fire-and-forget: never blocks the calling loop iteration, and + // any rejection is logged so it doesn't break the run loop. The successful + // arrival of the contributor will retrigger dependent computeds + // (kreditsByContributor, UserAvatar) via the `contributors.[]` dependency. + ensureContributorLoaded (id) { + if (!id) return; + if (this.contributors.findBy('id', id)) return; + if (this.contributorsFetching.has(id)) return; + + console.debug(`[kredits] Contributor ${id} not loaded yet; fetching in background`); + this.contributorsFetching.add(id); + this.fetchContributor(id) + .then(() => console.debug(`[kredits] Lazy-loaded contributor ${id}`)) + .catch(err => console.warn(`[kredits] Failed to lazy-load contributor ${id}:`, err)) + .finally(() => this.contributorsFetching.delete(id)); + }, + fetchContributors () { console.debug(`[kredits] Fetching all contributors from the network`); return this.kredits.Contributor.all() @@ -385,6 +418,7 @@ export default Service.extend({ const loadedContribution = this.contributions.findBy('id', contribution.id); if (loadedContribution) { this.contributions.removeObject(loadedContribution); } this.contributions.pushObject(contribution); + this.ensureContributorLoaded(data.contributorId); return contribution; }, @@ -620,6 +654,7 @@ export default Service.extend({ obj.set('contributor', this.contributors.findBy('id', data.recipientId)); this.removeObjectFromCollectionIfLoaded('reimbursements', obj.id); this.reimbursements.pushObject(obj); + this.ensureContributorLoaded(data.recipientId); return obj; }, diff --git a/tests/integration/components/user-avatar/component-test.js b/tests/integration/components/user-avatar/component-test.js index 674f263..4d63331 100644 --- a/tests/integration/components/user-avatar/component-test.js +++ b/tests/integration/components/user-avatar/component-test.js @@ -37,4 +37,20 @@ module('Integration | Component | user-avatar', function(hooks) { `https://avatars2.githubusercontent.com/u/318?v=3&s=512`); }); + test('renders empty src when contributor is undefined', async function(assert) { + this.set('contributor', undefined); + await render(hbs`{{user-avatar contributor=contributor}}`); + + assert.dom(this.element).hasText(''); + assert.equal(this.element.querySelector('img').getAttribute('src'), ''); + }); + + test('renders empty src when contributor has no github_uid', async function(assert) { + const noGithub = { name: 'Mystery', github_uid: null }; + this.set('contributor', noGithub); + await render(hbs`{{user-avatar contributor=contributor}}`); + + assert.equal(this.element.querySelector('img').getAttribute('src'), ''); + }); + }); diff --git a/tests/unit/services/kredits-test.js b/tests/unit/services/kredits-test.js index 526a5a4..16b5ca4 100644 --- a/tests/unit/services/kredits-test.js +++ b/tests/unit/services/kredits-test.js @@ -2,6 +2,8 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; import contributors from '../../fixtures/contributors'; import contributions from '../../fixtures/contributions'; +import ContributionModel from 'kredits-web/models/contribution'; +import processContributionData from 'kredits-web/utils/process-contribution-data'; module('Unit | Service | kredits', function(hooks) { setupTest(hooks); @@ -57,4 +59,33 @@ module('Unit | Service | kredits', function(hooks) { assert.ok(service.contributorsSorted instanceof Array, 'is an array'); assert.equal(service.contributorsSorted[1].name, 'Manuel', 'sorts by name'); }); + + test('#kreditsByContributor ignores unconfirmed contributions for contributors that are not loaded', function(assert) { + let service = this.owner.lookup('service:kredits'); + service.set('contributors', contributors); + + // Contribution referencing a contributor that is not in the contributors + // collection (e.g. its IPFS profile failed to deserialize). The dashboard + // must not throw when computing the toplist. + const orphanAttrs = { id: 99, contributorId: 999, confirmedAt: 2000, claimed: false, vetoed: false, amount: 7000, kind: 'dev' }; + const orphan = ContributionModel.create(processContributionData(orphanAttrs)); + orphan.set('contributor', undefined); + + service.set('contributions', [...contributions, orphan]); + service.set('currentBlock', 1023); + + let kreditsByContributor; + try { + kreditsByContributor = service.kreditsByContributor; + } catch (e) { + assert.ok(false, `did not expect kreditsByContributor to throw: ${e.message}`); + return; + } + + assert.ok(kreditsByContributor, 'computes without throwing'); + assert.notOk(kreditsByContributor.findBy('contributor', undefined), + 'no entry for the missing contributor'); + assert.notOk(kreditsByContributor.find(k => k.contributor && k.contributor.id === 999), + 'orphan contributor id is absent from the toplist'); + }); });