Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CardDef> = new Map();
#fileMetaInstances: Map<string, FileDef> = new Map();
#inFlight: Set<Promise<unknown>> = new Set();
Expand Down
42 changes: 42 additions & 0 deletions packages/base/query-field-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
// The sentinel `surfaceSearchResourceErrorState` planted on the most
// recent transition into an errored state, kept as an identity handle
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
193 changes: 193 additions & 0 deletions packages/host/tests/acceptance/markdown-embed-save-isolated-test.gts
Original file line number Diff line number Diff line change
@@ -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<typeof this> {
<template>
<div data-test-note-isolated>
<h1 data-test-note-title><@fields.title /></h1>
<@fields.body />
</div>
</template>
};
static edit = class Edit extends Component<typeof this> {
<template>
<div data-test-note-edit>
<h1 data-test-note-title><@fields.title /></h1>
<@fields.body />
</div>
</template>
};
}
`,
'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 {
<template>
<span data-test-pet-atom>{{@model.name}}</span>
</template>
};
static embedded = class Embedded extends Component {
<template>
<span data-test-pet-embedded>{{@model.name}}</span>
</template>
};
}
`,
'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');
});
});
Loading