diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index deda58a7fbd..073a7906a99 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -32,6 +32,7 @@ import { isSingleFileMetaDocument, isEntryCollectionDocument, isSparseItemResource, + loadCardDef, resolveFileDefCodeRef, searchEntryWireQueryFromQuery, getTypeRefsFromFilter, @@ -2005,8 +2006,7 @@ export default class StoreService extends Service implements StoreInterface { try { try { - await this.reloadInstance(instance); - maybeReloadedInstance = instance; + maybeReloadedInstance = await this.reloadInstance(instance); } catch (err: any) { if (err.status === 404) { // in this case the document was invalidated in the index because the @@ -2017,7 +2017,15 @@ export default class StoreService extends Service implements StoreInterface { maybeReloadedInstance = errorResponse.errors[0]; } } - if (!isCardInstance(maybeReloadedInstance)) { + // Detach the original instance's autosave subscription when it's been + // superseded: either the reload errored, or the card's type changed and + // reloadInstance built a fresh instance of the new type and swapped it + // into the identity map. When the reload updated the same object in + // place (the common case), keep its subscription. + if ( + !isCardInstance(maybeReloadedInstance) || + maybeReloadedInstance !== instance + ) { await this.stopAutoSaving(instance); } if (maybeReloadedInstance) { @@ -2831,10 +2839,13 @@ export default class StoreService extends Service implements StoreInterface { } } - private async reloadInstance(instance: CardDef): Promise { + // Returns the refreshed instance. Usually this is the same object as the + // one passed in (updated in place), but when the card's type changed it is a + // freshly-built instance of the new type — see below. + private async reloadInstance(instance: CardDef): Promise { // we don't await this in the realm subscription callback, so this test // waiter should catch otherwise leaky async in the tests - await this.withTestWaiters(async () => { + return await this.withTestWaiters(async () => { let api = await this.cardService.getAPI(); let incomingDoc: SingleCardDocument = (await this.cardService.fetchJSON( instance.id, @@ -2847,11 +2858,66 @@ export default class StoreService extends Service implements StoreInterface { ${JSON.stringify(incomingDoc, null, 2)}`, ); } + + // Scenario: a saved card instance changes its type — its JSON + // `meta.adoptsFrom` is edited to point at a different card definition + // (e.g. a realm index card re-pointed from CardsGrid to a custom index + // card). A card instance's JavaScript class is fixed at construction, so + // applying the new JSON to the existing object with `updateFromSerialized` + // would leave the old class in place and keep rendering the old type. + // Resolve the incoming type and, when it is not exactly the instance's + // class, rebuild from the new type. The comparison is exact (not a + // subtype check) so that re-pointing from a subclass to one of its + // ancestors also rebuilds instead of keeping the subclass instance. + let newDef: typeof BaseDef; + try { + newDef = await loadCardDef(incomingDoc.data.meta.adoptsFrom, { + loader: this.loaderService.loader, + relativeTo: new URL(instance.id), + }); + } catch (err: any) { + // `loadCardDef` throws a 404 CardError when the resolved module lacks + // the export. That's a "this client can't resolve the new type" error + // state for the card, not a deletion — `reloadTask` reserves a 404 for + // a genuinely removed instance (the 404 that `fetchJSON` throws above). + // Keep the error's detail but drop the 404 so it surfaces as an error + // state rather than a phantom delete. + if (isCardError(err) && err.status === 404) { + err.status = 422; + } + throw err; + } + + let currentDef = Reflect.getPrototypeOf(instance)?.constructor as + | typeof BaseDef + | undefined; + if (currentDef !== newDef) { + // Rebuild as the new type, reusing the existing local id so the + // identity map — and everything keyed by it, references included — + // keeps resolving this card to the rebuilt instance (the id-resolver + // also rejects a second local id for an already-known remote id). + // Construct directly rather than via `createFromSerialized`, whose + // cached-instance reuse would keep the old object when the new type is + // one of its ancestors; `updateFromSerialized` then deserializes into + // the new instance and swaps it into the identity map. + let rebuilt = new (newDef as typeof CardDef)({ + id: instance.id, + [localIdSymbol]: instance[localIdSymbol], + }); + await api.updateFromSerialized( + rebuilt, + incomingDoc, + this.store, + ); + return rebuilt; + } + await api.updateFromSerialized( instance, incomingDoc, this.store, ); + return instance; }); } diff --git a/packages/host/tests/acceptance/code-submode/editor-test.ts b/packages/host/tests/acceptance/code-submode/editor-test.ts index 8b365567e12..f07249e6f84 100644 --- a/packages/host/tests/acceptance/code-submode/editor-test.ts +++ b/packages/host/tests/acceptance/code-submode/editor-test.ts @@ -107,6 +107,34 @@ module('Acceptance | code submode | editor tests', function (hooks) { } } + `, + 'snack.gts': ` + import { contains, field, Component, CardDef } from "@cardstack/base/card-api"; + import StringField from "@cardstack/base/string"; + + export class Snack extends CardDef { + static displayName = 'Snack'; + @field name = contains(StringField); + static isolated = class Isolated extends Component { + + } + } + `, + 'treat.gts': ` + import { contains, field, Component, CardDef } from "@cardstack/base/card-api"; + import StringField from "@cardstack/base/string"; + + export class Treat extends CardDef { + static displayName = 'Treat'; + @field name = contains(StringField); + static isolated = class Isolated extends Component { + + } + } `, 'shipping-info.gts': ` import { contains, field, Component, FieldDef } from "@cardstack/base/card-api"; @@ -228,6 +256,19 @@ module('Acceptance | code submode | editor tests', function (hooks) { }, }, }, + 'Snack/cookie.json': { + data: { + attributes: { + name: 'Cookie', + }, + meta: { + adoptsFrom: { + module: `${testRealmURL}snack`, + name: 'Snack', + }, + }, + }, + }, 'Person/fadhlan.json': { data: { attributes: { @@ -545,6 +586,62 @@ module('Acceptance | code submode | editor tests', function (hooks) { .containsText('MangoXXX'); }); + test('changing a card instance adoptsFrom in the editor re-renders it as the new type after indexing', async function (assert) { + await visitOperatorMode({ + stacks: [ + [ + { + id: `${testRealmURL}Snack/cookie`, + format: 'isolated', + }, + ], + ], + submode: 'code', + codePath: `${testRealmURL}Snack/cookie.json`, + }); + + await waitFor('[data-test-code-mode-card-renderer-body] [data-test-snack]'); + assert + .dom('[data-test-code-mode-card-renderer-body] [data-test-snack]') + .containsText('Cookie', 'the preview renders as the original Snack type'); + assert + .dom('[data-test-code-mode-card-renderer-body] [data-test-treat]') + .doesNotExist(); + + // Re-point the card's meta.adoptsFrom at an unrelated type by editing its + // JSON in the editor. This exercises the full path through the realm + // server: save -> reindex -> index event -> store reload -> re-render. + setMonacoContent( + JSON.stringify({ + data: { + attributes: { + name: 'Cookie', + }, + meta: { + adoptsFrom: { + module: testRRI('treat'), + name: 'Treat', + }, + }, + }, + } as LooseSingleCardDocument), + ); + + await waitFor( + '[data-test-code-mode-card-renderer-body] [data-test-treat]', + { timeout: 10_000 }, + ); + assert + .dom('[data-test-code-mode-card-renderer-body] [data-test-treat]') + .containsText( + 'Cookie', + 'the preview re-rendered using the new Treat type', + ); + assert + .dom('[data-test-code-mode-card-renderer-body] [data-test-snack]') + .doesNotExist('the stale Snack render is gone'); + }); + test('card instance changes made in monaco editor are synchronized with store', async function (assert) { assert.expect(2); let expected: LooseSingleCardDocument = { diff --git a/packages/host/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index 9e5591b2a80..03a597e999e 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -88,6 +88,8 @@ module('Integration | Store', function (hooks) { let cardStore: CardStore; let PersonDef: typeof CardDefType; let BoomPersonDef: typeof CardDefType; + let EmployeeDef: typeof CardDefType; + let ManagerDef: typeof CardDefType; let realmService: RealmService; setupLocalIndexing(hooks); @@ -151,6 +153,27 @@ module('Integration | Store', function (hooks) { }; } BoomPersonDef = BoomPerson; + + class Employee extends CardDef { + static displayName = 'Employee'; + @field name = contains(StringField); + static isolated = class Isolated extends Component { + + }; + } + EmployeeDef = Employee; + + class Manager extends Employee { + static displayName = 'Manager'; + static isolated = class Isolated extends Component { + + }; + } + ManagerDef = Manager; loaderService = getService('loader-service'); loader = loaderService.loader; api = await loader.import('@cardstack/base/card-api'); @@ -165,6 +188,8 @@ module('Integration | Store', function (hooks) { contents: { 'person.gts': { Person }, 'boom-person.gts': { BoomPerson }, + 'employee.gts': { Employee }, + 'manager.gts': { Manager }, 'Person/hassan.json': new Person({ name: 'Hassan' }), 'Person/jade.json': new Person({ name: 'Jade' }), 'Person/queenzy.json': new Person({ name: 'Queenzy' }), @@ -2265,6 +2290,120 @@ module('Integration | Store', function (hooks) { ); }); + test('an instance live updates to a new type when its adoptsFrom changes', async function (assert) { + setCardInOperatorModeState(`${testRealmURL}Person/hassan`); + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + let original = storeService.peek( + `${testRealmURL}Person/hassan`, + ) as CardDefType; + assert.true( + original instanceof PersonDef, + 'the card starts out as a Person', + ); + assert.dom('[data-test-employee-badge]').doesNotExist(); + + // Re-point the card's meta.adoptsFrom at an unrelated type — mirrors a user + // changing the adoptsFrom of a realm index card (CardsGrid) to a custom + // index card by editing its JSON. + await testRealm.write( + 'Person/hassan.json', + JSON.stringify({ + data: { + attributes: { + name: 'Hassan', + }, + meta: { + adoptsFrom: { + module: testRRI('employee'), + name: 'Employee', + }, + }, + }, + } as LooseSingleCardDocument), + ); + + await waitUntil( + () => + storeService.peek(`${testRealmURL}Person/hassan`) instanceof + EmployeeDef, + { timeout: 5_000 }, + ); + let reloaded = storeService.peek( + `${testRealmURL}Person/hassan`, + ) as CardDefType; + assert.true( + reloaded instanceof EmployeeDef, + 'the store now holds an Employee instance for the same id', + ); + + await waitFor('[data-test-employee-badge]', { timeout: 5_000 }); + assert + .dom('[data-test-employee-badge]') + .containsText( + 'Employee: Hassan', + 'the card re-rendered using the new type', + ); + }); + + test('an instance rebuilds when its adoptsFrom changes to an ancestor type', async function (assert) { + await testRealm.write( + 'Manager/m1.json', + JSON.stringify({ + data: { + attributes: { name: 'Hassan' }, + meta: { + adoptsFrom: { module: testRRI('manager'), name: 'Manager' }, + }, + }, + } as LooseSingleCardDocument), + ); + setCardInOperatorModeState(`${testRealmURL}Manager/m1`); + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + await waitFor('[data-test-manager-badge]'); + let original = storeService.peek( + `${testRealmURL}Manager/m1`, + ) as CardDefType; + assert.true(original instanceof ManagerDef, 'the card starts as a Manager'); + + // Re-point at the ancestor type. A subtype check would treat the Manager + // instance as already compatible and keep rendering the subclass; the + // store must rebuild it as exactly Employee. + await testRealm.write( + 'Manager/m1.json', + JSON.stringify({ + data: { + attributes: { name: 'Hassan' }, + meta: { + adoptsFrom: { module: testRRI('employee'), name: 'Employee' }, + }, + }, + } as LooseSingleCardDocument), + ); + + await waitUntil( + () => { + let c = storeService.peek(`${testRealmURL}Manager/m1`); + return c instanceof EmployeeDef && !(c instanceof ManagerDef); + }, + { timeout: 5_000 }, + ); + await waitFor('[data-test-employee-badge]', { timeout: 5_000 }); + assert + .dom('[data-test-employee-badge]') + .containsText('Employee: Hassan', 'rebuilt as the ancestor type'); + assert + .dom('[data-test-manager-badge]') + .doesNotExist('no longer rendered as the Manager subclass'); + }); + test('an instance can live update thru an error state', async function (assert) { setCardInOperatorModeState(`${testRealmURL}Person/hassan`); await renderComponent(