diff --git a/.gitignore b/.gitignore index 3c3629e..b63696f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ node_modules + +# Local Claude Code project settings (permission allowlist, etc.) — not tracked +.claude/settings.json diff --git a/skills/boxel-patterns/SKILL.md b/skills/boxel-patterns/SKILL.md index 09857cb..93ddba5 100644 --- a/skills/boxel-patterns/SKILL.md +++ b/skills/boxel-patterns/SKILL.md @@ -35,6 +35,7 @@ Ready patterns below can be read and adapted. Each has `patterns//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 />`. diff --git a/skills/boxel-patterns/patterns/show-card-list-with-views/README.md b/skills/boxel-patterns/patterns/show-card-list-with-views/README.md index c94f0a3..6910071 100644 --- a/skills/boxel-patterns/patterns/show-card-list-with-views/README.md +++ b/skills/boxel-patterns/patterns/show-card-list-with-views/README.md @@ -63,4 +63,4 @@ export class CardsGrid extends GlimmerComponent { **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`. diff --git a/skills/boxel-patterns/patterns/show-list-prefer-prerendered/README.md b/skills/boxel-patterns/patterns/show-list-prefer-prerendered/README.md new file mode 100644 index 0000000..85d8b54 --- /dev/null +++ b/skills/boxel-patterns/patterns/show-list-prefer-prerendered/README.md @@ -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|}} +
  • + {{else}} +
  • {{if results.isLoading 'Loading…' 'No results'}}
  • + {{/each}} + +``` + +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. diff --git a/skills/boxel-patterns/patterns/show-list-prefer-prerendered/example.gts b/skills/boxel-patterns/patterns/show-list-prefer-prerendered/example.gts new file mode 100644 index 0000000..4e07ace --- /dev/null +++ b/skills/boxel-patterns/patterns/show-list-prefer-prerendered/example.gts @@ -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 { + // 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 + }; + } + + + }; +} + +// === 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 { +// 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. diff --git a/skills/boxel-patterns/patterns/show-table-from-query/README.md b/skills/boxel-patterns/patterns/show-table-from-query/README.md index 3cc01f8..2febd48 100644 --- a/skills/boxel-patterns/patterns/show-table-from-query/README.md +++ b/skills/boxel-patterns/patterns/show-table-from-query/README.md @@ -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`. diff --git a/skills/boxel/references/query-systems.md b/skills/boxel/references/query-systems.md index 21d8b75..0bbef2d 100644 --- a/skills/boxel/references/query-systems.md +++ b/skills/boxel/references/query-systems.md @@ -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 `` 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 diff --git a/skills/glossary.md b/skills/glossary.md index f76ceac..e0f06da 100644 --- a/skills/glossary.md +++ b/skills/glossary.md @@ -395,6 +395,7 @@ Ready patterns live at `boxel-patterns/patterns//{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.