Lazily render nested query results to keep large records cheap

Deeply nested records (e.g. BRP entries with related people several levels deep) could balloon into tens of thousands of DOM nodes for a single page of results. Nested containers now render their first page of children/entries eagerly and load further pages 10 at a time behind a "N more..." toggle, so a query that nests most of the dataset under a few top-level items (e.g. the dactal grouping query personen/geslacht) no longer dumps it all into the DOM on one click.

author
Marijn Besseling <njirambem@gmail.com> · 2026-08-17 19:38 UTC
commit
e7b74cd434d2c850f6520f5e2481151c2b6f4fa4
parent
af51f010d2
tree
browse at this commit

1 file changed +102 -13

Blog/Components/Pages/Query.razor.js +102 -13

@@ -248,20 +248,18 @@ async function runQuery(query, lang) {
248 248 }
249 249 }
250 250
251 -function renderValue(obj) {
251 +// How many children/entries a nested container reveals up front, and how
252 +// many more each "more..." click loads. Some queries (e.g. the dactal
253 +// grouping query personen/geslacht) produce a handful of top-level items
254 +// that each nest most of the dataset as children, so a click on "more" has
255 +// to pull in another page rather than the rest of the array in one go.
256 +const PAGE_SIZE = 10
257 +
258 +function renderValue(obj, depth = 0) {
252 259 if (Array.isArray(obj)) {
253 - return h("ol",
254 - obj.map((item) => h("li", [renderValue(item)]))
255 - )
256 - } else if (typeof obj === "object") {
257 - return h("ul",
258 - Object.entries(obj).map(([name, item]) =>
259 - h("li", {class: "dash"},
260 - [
261 - h("span", {class: "name"}, name),
262 - t(" "),
263 - renderValue(item)
264 - ])))
260 + return renderList(obj, depth)
261 + } else if (obj !== null && typeof obj === "object") {
262 + return renderContainer("ul", Object.entries(obj), ([name, item]) => renderEntry(name, item, depth))
265 263 } else if (typeof obj === "boolean") {
266 264 return h("span", {class: obj ? "json-true" : "json-false"})
267 265 } else if (typeof obj === "number") {
@@ -269,4 +267,95 @@ function renderValue(obj) {
269 267 } else if (typeof obj === "string") {
270 268 return h("span", obj)
271 269 }
270 +}
271 +
272 +function renderEntry(name, item, depth) {
273 + return h("li", {class: "dash"}, [
274 + h("span", {class: "name"}, name),
275 + t(" "),
276 + renderValue(item, depth + 1)
277 + ])
278 +}
279 +
280 +// Same idea as renderContainer, but for arrays: each page is its own
281 +// <ol start="...">, appended alongside (not inside) the previous one, so
282 +// numbering carries on (11, 12, 13, ...) instead of restarting at 1.
283 +function renderList(items, depth) {
284 + const renderItem = (item) => h("li", [renderValue(item, depth + 1)])
285 +
286 + if (depth === 0) {
287 + return h("ol", items.map(renderItem))
288 + }
289 +
290 + const container = h("div")
291 + appendListPage(container, items, renderItem, 0)
292 + return container
293 +}
294 +
295 +function appendListPage(container, items, renderItem, offset) {
296 + const page = items.slice(offset, offset + PAGE_SIZE)
297 + const ol = h("ol", page.map(renderItem))
298 + if (offset > 0) {
299 + ol.setAttribute("start", String(offset + 1))
300 + }
301 + container.appendChild(ol)
302 +
303 + const nextOffset = offset + page.length
304 + const rest = items.length - nextOffset
305 + if (rest === 0) {
306 + return
307 + }
308 +
309 + const details = h("details")
310 + details.appendChild(h("summary", `${rest} more…`))
311 + details.addEventListener("toggle", () => {
312 + if (details.open) {
313 + details.remove()
314 + appendListPage(container, items, renderItem, nextOffset)
315 + }
316 + }, {once: true})
317 +
318 + container.appendChild(details)
319 +}
320 +
321 +// Eagerly renders only the first page of entries of an object so a single
322 +// deeply nested record can't blow up the DOM; each further page is built
323 +// lazily, one PAGE_SIZE batch at a time, the first time its "more..."
324 +// toggle is expanded - so a record with hundreds of children never dumps
325 +// them all into the DOM from a single click. Objects are never the
326 +// top-level results value (that's always an array, see renderList), so
327 +// there's no depth-0 case to exempt here.
328 +function renderContainer(tag, items, renderItem) {
329 + const container = h(tag, [])
330 + appendContainerPage(container, items, renderItem, 0)
331 + return container
332 +}
333 +
334 +function appendContainerPage(container, items, renderItem, offset) {
335 + const page = items.slice(offset, offset + PAGE_SIZE)
336 + for (const item of page) {
337 + container.appendChild(renderItem(item))
338 + }
339 +
340 + const nextOffset = offset + page.length
341 + const rest = items.length - nextOffset
342 + if (rest === 0) {
343 + return
344 + }
345 +
346 + const details = h("details")
347 + details.appendChild(h("summary", `${rest} more…`))
348 +
349 + const moreItem = h("li", {class: "dash"}, [details])
350 + // The "toggle" event doesn't bubble, so it has to be bound to <details>
351 + // itself - but it's the wrapping <li> that needs removing, or an empty
352 + // dash bullet is left behind once the details element is gone.
353 + details.addEventListener("toggle", () => {
354 + if (details.open) {
355 + moreItem.remove()
356 + appendContainerPage(container, items, renderItem, nextOffset)
357 + }
358 + }, {once: true})
359 +
360 + container.appendChild(moreItem)
272 361 }
No newline at end of file