fix(filter-builder): id labels resolve at render — refresh keeps names

Refresh showed raw uuids in id-kind value cells (feed/library/
collection/added_by). Two defects, one story:

1. Only ids persist in filter trees; the client's id→label cache dies
   with the page. The quiet re-resolution fetch reused the suggest
   facet, whose rows are MEDIA-CONSTRAINED (INNER JOIN media_items +
   the tree's sibling rules as a constraint) — so an id whose media
   doesn't match got no row and no label. Live repro: "feed in [A,B,C]
   AND title contains Chapter" labeled one feed of three (live-request
   evidence: 200 with total:1). A label is a property of the entity
   row, not of what currently matches.

   Fix: pages that render a tree resolve labels SERVER-SIDE at render
   and embed the map on the mount (data-id-labels) — the collection
   editor (stored tree) and the bookshelf (URL qf). New
   SearchService.FilterTreeLabels reads the entity tables with no
   media join and no tree constraint; scoping rides the request
   identity through the scoped handle (podcast_feeds/collections RLS —
   an invisible id resolves to nothing; pinned), libraries mirror the
   facet's visibility join. A feed with ZERO episodes resolves its
   title — pinned; that case was structurally impossible before.

2. The bookshelf drawer never hydrated at page load at all: the only
   load-time hydrate calls live in the PARENT's x-init
   (initBookshelf), and Alpine initializes parents before nested
   components — builderData() was null there, so hydrateFromForm
   no-oped and a URL-qf refresh left the drawer with an empty seeded
   rule while the grid stayed filtered. The drawer now hydrates in its
   OWN init when the mount carries no data-tree; the init-order
   contract is pinned in vitest (185/185).

The quiet suggest fetch remains only as a fallback for trees the SSR
never saw; embedded labels make it a no-op on refresh. Interactive
menus are untouched (their constrained, count-bearing behavior is
correct for choosing).

Pins: TestRulesPageEmbedsIdLabels (zero-episode feed labels at render,
bookshelf qf library label, hidden library NOT labeled);
filter/episode-tree/search-contract/RLS/playlist container slices
green; contract_pins entry documents the embed contract.
This commit is contained in:
John O'Keefe committed 2026-11-01 10:44:48 -05:00
1 parent c448fa438f
commit 65f257f377
12 files changed
+533 -80

No files matched your search

+74
View File
@@ -496,3 +496,77 @@ describe("id-kind value cells", () => {
expect((c.filterTree.children[0] as FilterRuleState).values).toEqual(["lewis"]);
});
});
// ── SSR-embedded id labels + the bookshelf init-order fix (2026-11-01)
// The page handler resolves the tree's id-kind values at render time and
// embeds the id→name map on the mount (data-id-labels) — value cells
// show NAMES from the very first paint, with NO resolution fetch. And
// bookshelf mounts (no data-tree) hydrate their tree from the form at
// THIS component's init: the parent's x-init runs before this nested
// component exists (Alpine initializes parents first), so its
// builderData() call was null and refresh left the drawer empty.
describe("init: embedded labels + form hydration", () => {
beforeEach(() => {
ingestFilterFields(builderFields);
});
afterEach(() => {
document.body.innerHTML = "";
vi.restoreAllMocks();
});
const mount = (attrs: Record<string, string>): HTMLElement => {
const el = document.createElement("div");
el.setAttribute("x-data", "filterBuilderExtra");
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
document.body.appendChild(el);
return el;
};
const qfJSON = JSON.stringify({
mode: "all",
children: [{ field: "library", op: "is", values: ["lib-1"] }],
});
it("init ingests the embedded labels — cells show names, no fetch", () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const el = mount({
"data-filter-fields": JSON.stringify(builderFields),
"data-tree": JSON.stringify({
mode: "all",
children: [{ field: "library", op: "is", values: ["lib-1"] }],
}),
"data-id-labels": JSON.stringify({ "lib-1": "Comics" }),
});
const c = filterBuilderData();
(c as unknown as { $el: HTMLElement }).$el = el;
c.init();
expect(c.filterTree.children).toHaveLength(1);
expect(c.valueCellText([0])).toBe("Comics");
expect(fetchSpy).not.toHaveBeenCalled();
});
it("init without data-tree hydrates from the form's qf (bookshelf)", () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const form = document.createElement("form");
form.id = "filter-form";
const qf = document.createElement("input");
qf.name = "qf";
qf.value = qfJSON;
form.appendChild(qf);
document.body.appendChild(form);
const el = mount({
"data-filter-fields": JSON.stringify(builderFields),
"data-id-labels": JSON.stringify({ "lib-1": "Comics" }),
});
const c = filterBuilderData();
(c as unknown as { $el: HTMLElement }).$el = el;
c.init();
// The tree hydrated (pre-fix it stayed EMPTY — the parent's x-init
// could not reach this component), and the cell shows the embedded
// label, not the raw id.
expect(c.filterTree.children).toEqual([{ field: "library", op: "is", values: ["lib-1"] }]);
expect(c.valueCellText([0])).toBe("Comics");
expect(fetchSpy).not.toHaveBeenCalled();
});
});
+24 -2
View File
@@ -462,6 +462,19 @@ export function filterBuilderData() {
// source-of-truth; never invent rows client-side.
}
}
// SSR-embedded id labels (2026-11-01): the page handler resolved
// the tree's id-kind values to names at render time and embedded
// the map here — ingest it BEFORE any tree lands, so the cells
// show names from the very first paint and resolveIdLabels (the
// quiet fetch below) early-returns instead of hitting the server.
const labelsRaw = el?.dataset.idLabels;
if (labelsRaw) {
try {
Object.assign(this.idLabels, JSON.parse(labelsRaw) as Record<string, string>);
} catch {
// malformed embed — cells fall back to raw ids, as before
}
}
// Collection mounts (item 3.4): the page carries the collection's
// scope (row universe for counts/apply) and its PREFILL tree (the
// stored tree, or the legacy rules' conversion).
@@ -478,9 +491,18 @@ export function filterBuilderData() {
} catch {
// no prefill — start empty
}
} else {
// Bookshelf mounts carry NO data-tree: the wire state lives in
// the form (applyURLFilters put the URL's qf there). It must
// hydrate HERE, at this component's init — the parent's x-init
// (initBookshelf) runs BEFORE this nested component exists
// (Alpine initializes parents first), so its builderData()
// call is null and its hydrateFromForm never lands (2026-11-01:
// a refresh left the drawer empty — no rules at all).
this.hydrateFromForm();
}
// Stored id values arrive bare: resolve their labels now so the
// cells render names from the first paint after reload.
// Anything still unlabeled (a tree hydrated from a form the SSR
// didn't know about) resolves through the quiet fetch fallback.
this.resolveIdLabels();
},
// treeJSON is the APPLY wire for collection mounts: the page's