Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
node_modules

# Local Claude Code project settings (permission allowlist, etc.) — not tracked
.claude/settings.json
1 change: 1 addition & 0 deletions skills/boxel-patterns/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Ready patterns below can be read and adapted. Each has `patterns/<slug>/README.m

- **`app-card-home-with-search`** *(README-only)* — The home CardDef for any card family: `prefersWideFormat = true` + one `@context.searchResultsComponent` section per CardDef, live-updating. **Build this whenever you build 2+ related CardDefs.**
- `show-card-list-with-views` — Generic CardsGrid that takes `Query` + realms + a view name (`card` / `strip` / `grid`) and renders via `@context.searchResultsComponent` (entry-rooted, live by default). The lower-level building block used inside `app-card-home-with-search`.
- **`show-list-prefer-prerendered`** — The cost decision for any card that *lists* cards: render the cheap prerendered `@context.searchResultsComponent` stream, and reserve the instance-hydrating getters (`getCards` / `getCardCollection` / `store.search`) for rows you genuinely read or mutate — scoped to the current realm. Read this before writing the query for a browse/feed/roster view.
- `show-count-tiles-from-query` — Dashboard count tiles that issue `page: { size: 1 }` queries and read `results.meta.page.total` from `@context.searchResultsComponent` (with `@mode='none'`, since only the count is needed). Use for overview badges, operational signals, inbox counts, and clickable dashboard sections without rendering every matching card.
- `show-table-from-query` — Reusable table that takes a `Query` + realm and renders any cards-of-type as sortable rows. WeakMap field-component caching.
- `show-runtime-markdown-html` — Render BFM/markdown to HTML at runtime in `isolated`/`embedded` templates via MarkdownField + `<@fields.body />`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,4 @@ export class CardsGrid extends GlimmerComponent<CardsGridSignature> {

**Source:** `boxel-catalog/components/grid.gts`, `boxel-catalog/components/card-list.gts`.

**See also:** `pick-typed-sort`, `show-table-from-query`, `boxel/references/query-systems.md`, `boxel/references/fitted-formats.md`.
**See also:** `show-list-prefer-prerendered` (why this cheap surface is the default over hydrating getters), `pick-typed-sort`, `show-table-from-query`, `boxel/references/query-systems.md`, `boxel/references/fitted-formats.md`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
validated: source-proven
---

# show-list-prefer-prerendered — Render display lists via the prerendered stream, not hydrated instances

**What this gives you:** A default for any card that *shows a list of other cards* — render the prerendered `@context.searchResultsComponent` stream, and reserve the instance-hydrating getters (`getCards` / `getCardCollection` / `store.search`) for the rows you genuinely read or mutate. The cheap path scales to large realms without a per-row server round-trip.

**When to use:** Every list / grid / feed / roster / directory / search-result UI whose job is to *display* cards. Reach for this decision before you write the query — it's the one that keeps a browse view from silently becoming an N-round-trip hydration.

**The insight:** The card-facing search APIs split by **cost**, and the ergonomic-sounding ones are the expensive ones:

| Surface | What it returns | Cost | Use it for |
|---|---|---|---|
| `@context.searchResultsComponent` (base `CardList` / `CardsGrid` wrap it) | A stream of prerendered `entry` rows — inert HTML, hydrated lazily only when a row is interacted with | **Cheap.** No server `loadLinks`, no serialization, no Store hydration until a row is opened | Any list you only look at |
| `getCards` / `getCardCollection` (reactive) · `@context.store.search` (imperative) | Live `CardDef` instances | **Expensive.** Server `loadLinks` + serialization + Store hydration for *every* matching row — including rows the user never opens | Genuine read / mutate: reading a field off each row, editing, computing a rollup |

Rendering an `entry` costs a prerendered-HTML fetch; hydrating an instance costs a full round-trip *per card*. A display list of a few hundred cards is a few hundred hydrations you never needed. The right shape is: render the stream, and resolve a **single** live instance only when a row is actually opened.

## Recipe shape

Build an entry-rooted query from an ordinary `Query` with `searchEntryWireQueryFromQuery`, scope it to the realm the card lives in (via the `realmURL` Symbol), and hand it to `@context.searchResultsComponent`. Each yielded `entry.component` renders itself, so the template never branches on prerendered-vs-live.

```gts
import {
codeRef,
realmURL,
searchEntryWireQueryFromQuery,
type SearchEntryWireQuery,
} from '@cardstack/runtime-common';

// @ts-expect-error import.meta is host-supported
const here: string = import.meta.url;

get listQuery(): SearchEntryWireQuery {
let realm = this.args.model?.[realmURL]?.href;
return {
...searchEntryWireQueryFromQuery({
filter: { type: codeRef(here, './contact', 'Contact') },
sort: [{ by: 'cardTitle', direction: 'asc' }],
}),
realms: realm ? [realm] : [], // current realm only
};
}
```

```hbs
<@context.searchResultsComponent @query={{this.listQuery}} @mode='hover' as |results|>
{{#each results.entries key='id' as |entry|}}
<li><entry.component /></li>
{{else}}
<li>{{if results.isLoading 'Loading…' 'No results'}}</li>
{{/each}}
</@context.searchResultsComponent>
```

The full worked contrast — the cheap default plus the commented "only when you need the instances" getter, scoped to the current realm — is in `example.gts`.

**When a data-getter is genuinely needed, scope it to the current realm.** If the template really does read a field off each row or mutate it, use a hydrating getter — but pass the **current realm** (`this.args.model?.[realmURL]?.href`) as the only search realm, not the whole federation. Hydrating one realm's worth of rows is bounded; hydrating every reachable realm is not. `realmURL` is the Symbol the host injects — import it from `@cardstack/runtime-common`; `Symbol.for('realmURL')` gives you a different Symbol that won't match (see `boxel/references/query-systems.md`).

**Gotchas:**
- "I need to sort / filter the list" is **not** a reason to hydrate — the query does sorting and filtering server-side; the prerendered stream reflects it. You only need instances for values the query can't express (cross-field computation in JS) or for mutation.
- If you need one field per row in a *table* (cell-level access), that genuinely needs instances — use `getCards` and see `show-table-from-query`. A whole rendered card per row does not.
- This is defense-in-depth guidance, not enforcement. It's a nudge toward the cheap path; nothing stops a card from hydrating a display list — which is exactly why the default has to be stated.

**Source:** `boxel-catalog/components/card-list.gts`, `boxel-catalog/components/grid.gts` (the `@context.searchResultsComponent` list surface); the cost split follows the host's search-API contract documented in `boxel/references/query-systems.md`.

## See also

- `show-card-list-with-views` — the lower-level reusable grid built on `@context.searchResultsComponent` (card / strip / grid views).
- `show-table-from-query` — the counterpart for when you *do* need instances: cell-level field access via `getCards`.
- `app-card-home-with-search` — the home CardDef that composes one prerendered search section per CardDef in a family.
- `boxel/references/query-systems.md` — "When to use what to query cards", the `realmURL` scoping rule, and the entry-rooted query shape.
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { CardDef, Component } from 'https://cardstack.com/base/card-api';
import {
codeRef,
realmURL,
searchEntryWireQueryFromQuery,
type SearchEntryWireQuery,
} from '@cardstack/runtime-common';

// 🧩 PATTERN: For a display list, render the prerendered result stream —
// do NOT hydrate live instances.
//
// A card that shows a *list* of other cards has two families of APIs, split
// by cost:
//
// CHEAP → @context.searchResultsComponent renders the prerendered `entry`
// stream: inert HTML, hydrated lazily only when a row is interacted
// with. No server loadLinks, no serialization, no Store hydration
// until a row is opened. This is the default for any list / grid /
// feed / roster the user only looks at. (The base CardList /
// CardsGrid components wrap this same surface.)
//
// COSTLY → getCards / getCardCollection (reactive) and store.search
// (imperative) return live CardDef instances. Each one triggers
// server-side loadLinks + serialization + Store hydration for EVERY
// matching row — even the rows the user never opens. Reserve these
// for genuine read/mutate needs (reading a field off each row,
// editing, computing a rollup), never for "I just want to show
// them".
//
// Resolve a single live instance only when a row is actually opened.

// @ts-expect-error import.meta is host-supported
const here: string = import.meta.url;

export class Directory extends CardDef {
static displayName = 'Directory';

static isolated = class Isolated extends Component<typeof Directory> {
// Build an entry-rooted query from an ordinary query, then scope it to the
// realm this card lives in. `realmURL` is the Symbol the host injects —
// import it from runtime-common; never Symbol.for('realmURL').
get listQuery(): SearchEntryWireQuery {
let realm = this.args.model?.[realmURL]?.href;
return {
...searchEntryWireQueryFromQuery({
filter: { type: codeRef(here, './contact', 'Contact') },
sort: [{ by: 'cardTitle', direction: 'asc' }],
}),
realms: realm ? [realm] : [], // current realm only
};
}

<template>
{{! ✅ DEFAULT — display list via the prerendered stream (cheap). }}
<ul class='contacts'>
<@context.searchResultsComponent
@query={{this.listQuery}}
@mode='hover'
as |results|
>
{{#each results.entries key='id' as |entry|}}
<li><entry.component /></li>
{{else}}
<li>{{if results.isLoading 'Loading…' 'No contacts yet'}}</li>
{{/each}}
</@context.searchResultsComponent>
</ul>

<style scoped>
.contacts { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.5rem; }
</style>
</template>
};
}

// === When you GENUINELY need the instances (read a field, mutate, roll up) ===
//
// Only then reach for a hydrating getter — and scope it to the CURRENT realm so
// you hydrate one realm's worth of rows, not the whole federation. Passing the
// current realm (via the realmURL Symbol) is the load-bearing part.
//
// class WithData extends Component<typeof Directory> {
// get realms(): string[] {
// let realm = this.args.model?.[realmURL]?.href;
// return realm ? [realm] : []; // current realm only — NOT every realm
// }
//
// // reactive: .instances is CardDef[], .isLoading flips while loading
// contacts = this.args.context?.getCards(
// this,
// () => ({ filter: { type: codeRef(here, './contact', 'Contact') } }),
// () => this.realms,
// { isLive: true },
// );
//
// // imperative one-shot alternative: this.args.context?.store.search(...)
// // returns instances directly — scope it to this.realms the same way.
// }
//
// Rule of thumb: if the template only *renders* each row, you do not need the
// instance — use the prerendered stream above.
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ validated: source-proven

**Source:** catalog-realm `components/table.gts:33-45` (TableSignature), `components/field-renderer.gts:40-79` (WeakMap cache), `components/grid.gts:16-47` (the grid variant).

**See also:** `automate-linked-to-me-lookup`, `show-card-list-with-views`, `boxel/references/query-systems.md`, `boxel/references/fitted-formats.md`.
**See also:** `show-list-prefer-prerendered` (when a display list should *not* hydrate instances the way this table does), `automate-linked-to-me-lookup`, `show-card-list-with-views`, `boxel/references/query-systems.md`, `boxel/references/fitted-formats.md`.
6 changes: 3 additions & 3 deletions skills/boxel/references/query-systems.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,9 @@ For benchmark-style coverage, exercise both common query surfaces across the set

The newer display surface for a list of results (the `<SearchResults>` component). Declare an **`entry`-rooted** query and render the yielded entries; each `entry.component` renders itself — prerendered HTML (inert, hydrated lazily on interaction) or a live card — so the card never branches on which.

**When to use what to query cards:**
- Display a list of results (cards or files) → `@context.searchResultsComponent`.
- Need the instances in JS (read / manipulate) → `getCards` (reactive) or `@context.store.search` (imperative, returns instances).
**When to use what to query cards** (this is a **cost** decision — the display surface is cheap, the instance getters hydrate every row; see the pattern `show-list-prefer-prerendered`):
- Display a list of results (cards or files) → `@context.searchResultsComponent`. Prerendered HTML, hydrated lazily per row. **Default for anything you only render.**
- Need the instances in JS (read / manipulate / mutate) → `getCards` / `getCardCollection` (reactive) or `@context.store.search` (imperative). These trigger server `loadLinks` + serialization + Store hydration for every matching row — reserve for genuine read/mutate, and scope to the current realm (`this.args.model?.[realmURL]?.href`), not the whole federation.
- Treat a query result as a field → query-backed fields (`linksTo` / `linksToMany` with a `query`).

```gts
Expand Down
1 change: 1 addition & 0 deletions skills/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ Ready patterns live at `boxel-patterns/patterns/<slug>/{README.md, example.gts}`
### Show
- **`app-card-home-with-search`** — Home CardDef for any card family.
- **`show-card-list-with-views`** — Generic CardsGrid with view names.
- **`show-list-prefer-prerendered`** — Cost decision for list UIs: render the cheap prerendered `@context.searchResultsComponent` stream; reserve `getCards` / `getCardCollection` / `store.search` (which hydrate every row) for genuine read/mutate, scoped to the current realm.
- **`show-count-tiles-from-query`** — Dashboard count tiles via `page: { size: 1 }` + `meta.page.total`.
- **`show-table-from-query`** — Sortable rows from a query.
- **`show-runtime-markdown-html`** — Render BFM/markdown at runtime in templates.
Expand Down