diff --git a/.changelog/28005.txt b/.changelog/28005.txt new file mode 100644 index 00000000000..df19744dbfd --- /dev/null +++ b/.changelog/28005.txt @@ -0,0 +1,3 @@ +```release-note:bug +ui: Fix service detail page not rendering +``` diff --git a/ui/app/adapters/watchable.js b/ui/app/adapters/watchable.js index bb1f1fb656e..d3fa145c68a 100644 --- a/ui/app/adapters/watchable.js +++ b/ui/app/adapters/watchable.js @@ -218,9 +218,36 @@ export default class Watchable extends ApplicationAdapter { json, ); if (replace) { - store.unloadAll(relationship.type); + // Capture existing record IDs before pushing new data. + // We must push first so that the relationship's LID references are + // updated before any old records are removed. Calling + // store.unloadAll() first leaves dangling LID references in the + // hasMany relationship state, which causes Ember Data 4.12+ to + // throw "Cannot create a record ... as no resource data exists" + // when the relationship is accessed. + const existingIds = store + .peekAll(relationship.type) + .map((r) => r.id); + const newIds = new Set( + (normalizedData.data || []).map((r) => r.id), + ); + + store.push(normalizedData); + + // Remove records that were not present in the new payload using + // removeRecord, which safely unlinks the record from all of its + // relationships before unloading it from the store. + existingIds.forEach((id) => { + if (!newIds.has(id)) { + const record = store.peekRecord(relationship.type, id); + if (record) { + removeRecord(store, record); + } + } + }); + } else { + store.push(normalizedData); } - store.push(normalizedData); }, (error) => { if (error instanceof AbortError || error.name === 'AbortError') { diff --git a/ui/tests/unit/adapters/job-test.js b/ui/tests/unit/adapters/job-test.js index 3e88c58b6da..06fb4c0ba01 100644 --- a/ui/tests/unit/adapters/job-test.js +++ b/ui/tests/unit/adapters/job-test.js @@ -278,6 +278,73 @@ module('Unit | Adapter | Job', function (hooks) { ); }); + test('reloadRelationship with replace:true pushes new records before removing stale ones, preventing dangling LID references', async function (assert) { + await this.initializeUI(); + + // Create two services for job-1 in mirage + this.server.create('service', { + id: 'svc-keep', + jobId: 'job-1', + namespace: 'default', + }); + this.server.create('service', { + id: 'svc-remove', + jobId: 'job-1', + namespace: 'default', + }); + + // Load the job and trigger the initial services relationship load + const job = await this.store.findRecord( + 'job', + JSON.stringify(['job-1', 'default']), + ); + await job.services; + await settled(); + + assert.strictEqual( + this.store.peekAll('service').length, + 2, + 'Initially 2 services are in the store', + ); + + // Simulate a server-side change: svc-remove is gone from the server + this.server.db.services.remove('svc-remove'); + + // Reload the services relationship with replace: true + await this.store.adapterFor('job').reloadRelationship(job, 'services', { + replace: true, + }); + await settled(); + + assert.notOk( + this.store.peekRecord('service', 'svc-remove'), + 'svc-remove is removed from the store after the replace reload', + ); + assert.ok( + this.store.peekRecord('service', 'svc-keep'), + 'svc-keep is still in the store after the replace reload', + ); + + // Critically, accessing the job's services relationship must not throw + // the "Cannot create a record ... as no resource data exists" error that + // Ember Data 4.12 raises when a hasMany relationship still holds a dangling + // LID for a record that was removed via store.unloadAll(). + let errorThrown = null; + try { + const currentServices = job.hasMany('services').value(); + if (currentServices) { + currentServices.forEach((s) => void s.serviceName); + } + } catch (e) { + errorThrown = e; + } + assert.strictEqual( + errorThrown, + null, + 'Accessing job.services after a replace reload does not throw', + ); + }); + test('findAll can be canceled', async function (assert) { await this.initializeUI(); diff --git a/ui/tests/unit/serializers/network-test.js b/ui/tests/unit/serializers/network-test.js index 18049d93ea8..77e8f8803cd 100644 --- a/ui/tests/unit/serializers/network-test.js +++ b/ui/tests/unit/serializers/network-test.js @@ -44,4 +44,15 @@ module('Unit | Serializer | Network', function (hooks) { undefined, ); }); + + test('missing IP does not throw', async function (assert) { + const original = { + ReservedPorts: [{ Label: 'http', Value: 80 }], + }; + + assert.deepEqual( + this.subject().normalize(NetworkModel, original).data.attributes.ip, + undefined, + ); + }); }); diff --git a/ui/tests/unit/serializers/port-test.js b/ui/tests/unit/serializers/port-test.js index 0e680044731..21c45039c20 100644 --- a/ui/tests/unit/serializers/port-test.js +++ b/ui/tests/unit/serializers/port-test.js @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2015, 2025 + * Copyright IBM Corp. 2015, 2026 * SPDX-License-Identifier: BUSL-1.1 */