diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 5ddca01807..8f2cb30bf8 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -4891,6 +4891,12 @@ function myLoader(): Loader { } class FallbackCardStore implements CardStore { + // Marks this as the storeless fallback (`getStore` hands one back when an + // instance isn't registered in `stores`). Its `getSearchResource` returns a + // permanently-empty, non-updating resource, so query-backed relationships + // resolved against it can never populate. Query-field support reads this brand + // to rebuild such a resource once a real store is available. [CS-12111] + readonly isFallbackCardStore = true; #instances: Map = new Map(); #fileMetaInstances: Map = new Map(); #inFlight: Set> = new Set(); diff --git a/packages/base/query-field-support.ts b/packages/base/query-field-support.ts index 2c08cbc1c0..29a36b871d 100644 --- a/packages/base/query-field-support.ts +++ b/packages/base/query-field-support.ts @@ -58,6 +58,16 @@ interface QueryFieldState { status?: number; }>; searchResource?: StoreSearchResource; + // Provenance of `searchResource`, used to detect an inert cached resource that + // must be rebuilt rather than reused. A resource built against a + // FallbackCardStore returns a permanently-empty result set, and one built + // before its `$this.*` inputs resolved has no query to run. Both happen when a + // query field is first read in edit mode (a nested FieldDef has no real store, + // and a just-created card's refs aren't resolved yet); reusing that resource + // in the saved isolated/embedded render is what left freshly-added embeds as + // broken links. [CS-12111] + resourceStoreWasFallback?: boolean; + resourceHadResolvableQuery?: boolean; renderCycleBarrier?: Promise; // The sentinel `surfaceSearchResourceErrorState` planted on the most // recent transition into an errored state, kept as an identity handle @@ -119,6 +129,30 @@ export function ensureQueryFieldSearchResource( queryFieldState.set(field.name, fieldState); } let searchResource = fieldState.searchResource; + if (searchResource) { + // Rebuild an inert cached resource once a real store / resolvable query is + // available. A resource first built against a FallbackCardStore (a nested + // FieldDef read in edit mode) or before `$this.*` resolved (a just-created + // card) never populates on its own; the saved isolated/embedded render + // reuses it and shows freshly-added embeds as broken links. The `store` + // brand and query resolvability are the two "is it inert?" signals. The + // rebuilt resource records real provenance below, so this is a one-shot + // transition — it does not re-fire on later renders (and never fires while + // the store is still the fallback, which would loop). [CS-12111] + let storeIsFallback = Boolean((store as any)?.isFallbackCardStore); + let rebuildInertResource = + (fieldState.resourceStoreWasFallback === true && !storeIsFallback) || + (fieldState.resourceHadResolvableQuery === false && + !storeIsFallback && + Boolean(resolveQueryAndRealm(instance, field, fieldDefinition)?.query)); + if (rebuildInertResource) { + log.debug( + `ensureQueryFieldSearchResource: rebuilding inert resource for field=${field.name} (storeWasFallback=${fieldState.resourceStoreWasFallback}, hadResolvableQuery=${fieldState.resourceHadResolvableQuery})`, + ); + fieldState.searchResource = undefined; + searchResource = undefined; + } + } if (searchResource) { // Intentionally do NOT call `trackQueryFieldLoads` here. The barrier // it registers exists to plug a one-shot timing race at resource @@ -190,6 +224,14 @@ export function ensureQueryFieldSearchResource( }, ); fieldState.searchResource = searchResource; + // Record whether this resource was built against an inert store or before its + // query resolved, so a later read with a real store / resolvable query + // rebuilds it instead of reusing the empty result (see the reuse branch and + // the QueryFieldState fields). [CS-12111] + fieldState.resourceStoreWasFallback = Boolean( + (store as any)?.isFallbackCardStore, + ); + fieldState.resourceHadResolvableQuery = Boolean(args()?.query); trackQueryFieldLoads(store, field.name, fieldState); surfaceSearchResourceErrorState(fieldState, instance, field, searchResource); // Bridge `getRelationshipMembershipState(...).isLoading` to this freshly-created resource: diff --git a/packages/host/tests/acceptance/markdown-embed-save-isolated-test.gts b/packages/host/tests/acceptance/markdown-embed-save-isolated-test.gts new file mode 100644 index 0000000000..5d2a3d7543 --- /dev/null +++ b/packages/host/tests/acceptance/markdown-embed-save-isolated-test.gts @@ -0,0 +1,193 @@ +import { click, settled, waitFor, waitUntil } from '@ember/test-helpers'; + +import { module, test } from 'qunit'; + +import cmContext from '@cardstack/host/lib/codemirror-context'; + +import { + setupAuthEndpoints, + setupLocalIndexing, + setupOnSave, + setupRealmCacheTeardown, + setupUserSubscription, + setupAcceptanceTestRealm, + SYSTEM_CARD_FIXTURE_CONTENTS, + testRealmURL, + visitOperatorMode, + withCachedRealmSetup, +} from '../helpers'; +import { setupMockMatrix } from '../helpers/mock-matrix'; +import { setupApplicationTest } from '../helpers/setup'; + +// CS-12111: an embed inserted into a Rich Markdown field previews correctly in +// the compose (edit) view, but after saving, the containing card's isolated +// view showed the embed as a broken link. The compose view resolves refs via a +// live `getCards` search; the saved isolated view resolves only through the +// query-backed `linkedCards` / `linkedFiles` relationships. This suite drives +// the real edit -> insert -> save -> isolated flow so the embed must resolve in +// isolated the same way it did in compose. +module('Acceptance | markdown embed save then isolated', function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + setupRealmCacheTeardown(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [testRealmURL], + }); + + const noteId = `${testRealmURL}Note/welcome`; + + hooks.beforeEach(async function () { + let { createAndJoinRoom } = mockMatrixUtils; + createAndJoinRoom({ sender: '@testuser:localhost', name: 'room-test' }); + setupUserSubscription(); + setupAuthEndpoints(); + + await withCachedRealmSetup(async () => + setupAcceptanceTestRealm({ + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + 'note.gts': ` + import { CardDef, Component, contains, field, StringField } from '@cardstack/base/card-api'; + import { RichMarkdownField } from '@cardstack/base/rich-markdown'; + export class Note extends CardDef { + static displayName = 'Note'; + @field title = contains(StringField); + @field body = contains(RichMarkdownField); + static isolated = class Isolated extends Component { + + }; + static edit = class Edit extends Component { + + }; + } + `, + 'pet.gts': ` + import { CardDef, Component, contains, field, StringField } from '@cardstack/base/card-api'; + export class Pet extends CardDef { + static displayName = 'Pet'; + @field name = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function () { + return this.name; + }, + }); + static atom = class Atom extends Component { + + }; + static embedded = class Embedded extends Component { + + }; + } + `, + 'Pet/mango.json': { + data: { + attributes: { name: 'Mango', cardTitle: 'Mango' }, + meta: { adoptsFrom: { module: '../pet', name: 'Pet' } }, + }, + }, + 'documents/notes.txt': 'These are project notes.', + 'Note/welcome.json': { + data: { + attributes: { + title: 'Welcome', + cardTitle: 'Welcome', + body: { content: '' }, + }, + meta: { adoptsFrom: { module: '../note', name: 'Note' } }, + }, + }, + }, + }), + ); + }); + + // Insert `source` into the mounted CodeMirror editor for the open Note, then + // toggle back to isolated. Toggling tears down the editor, which flushes the + // debounced save — the same save the app performs — so the isolated render + // reads the freshly-saved (and reindexed) body. + async function insertDirectiveThenViewIsolated(source: string) { + await click(`[data-test-operator-mode-stack="0"] [data-test-edit-button]`); + await waitFor( + `[data-test-stack-card="${noteId}"] [data-test-codemirror-editor]`, + { timeout: 5000 }, + ); + let editorEl = document.querySelector( + `[data-test-stack-card="${noteId}"] [data-test-codemirror-editor] .cm-editor`, + ) as HTMLElement | null; + let view = editorEl ? cmContext.EditorView.findFromDOM(editorEl) : null; + view!.focus(); + view!.dispatch({ changes: { from: 0, insert: source } }); + await settled(); + // Toggle back to isolated (flushes the pending save on editor teardown). + await click(`[data-test-operator-mode-stack="0"] [data-test-edit-button]`); + await waitUntil( + () => + !document.querySelector( + `[data-test-stack-card="${noteId}"] [data-test-codemirror-editor]`, + ), + { timeout: 5000 }, + ); + await settled(); + } + + test('a just-inserted card embed resolves in the isolated view after save', async function (assert) { + await visitOperatorMode({ + stacks: [[{ id: noteId, format: 'isolated' }]], + }); + + await insertDirectiveThenViewIsolated(`:card[../Pet/mango]`); + + await waitFor(`[data-test-stack-card="${noteId}"] [data-test-pet-atom]`, { + timeout: 5000, + }); + assert + .dom(`[data-test-stack-card="${noteId}"] [data-test-pet-atom]`) + .hasText('Mango', 'the embedded card resolves in the isolated view'); + assert + .dom( + `[data-test-stack-card="${noteId}"] [data-test-markdown-bfm-unresolved-inline]`, + ) + .doesNotExist('the embed is not rendered as a broken link'); + }); + + test('a just-inserted file embed resolves in the isolated view after save', async function (assert) { + await visitOperatorMode({ + stacks: [[{ id: noteId, format: 'isolated' }]], + }); + + await insertDirectiveThenViewIsolated(`:file[../documents/notes.txt]`); + + await waitFor( + `[data-test-stack-card="${noteId}"] [data-test-markdown-bfm-inline-file]`, + { timeout: 5000 }, + ); + assert + .dom( + `[data-test-stack-card="${noteId}"] [data-test-markdown-bfm-inline-file]`, + ) + .exists('the embedded file resolves in the isolated view'); + assert + .dom( + `[data-test-stack-card="${noteId}"] [data-test-markdown-bfm-unresolved-inline]`, + ) + .doesNotExist('the file embed is not rendered as a broken link'); + }); +});