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:
1 parent
c448fa438f
commit
65f257f377
12 files changed
+533
-80
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user