From b9c067f1934881588961c1ed7ba12b137208ea9b Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Sat, 28 Feb 2026 01:16:20 +0700 Subject: [PATCH 1/5] fix(reader): load all pasals SSR for structured (BAB-based) laws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The usePagination flag was based solely on pasal count (>= 100), but client-side infinite scroll doesn't work when pasals are rendered per-BAB server-side — only the initial 30 SSR pasals were ever shown under their BABs, leaving all subsequent BABs empty. Fix: skip client pagination when the law has BABs/aturan/lampiran structure nodes, and always fetch the full pasal set SSR instead. Flat laws (no BABs) with 100+ pasals still use infinite scroll. Co-authored-by: Claude --- .../src/app/[locale]/peraturan/[type]/[slug]/page.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx b/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx index 1aec8d3..83de592 100644 --- a/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx @@ -252,12 +252,18 @@ async function LawReaderSection({ .order("id"), ]); - const usePagination = (totalPasalCount || 0) >= 100; + // Structured (BAB-based) laws must always load all pasals SSR so the BAB-grouping + // logic has the full set. Only use client-side infinite scroll for flat laws (no BABs) + // with a large pasal count. + const hasBABs = (structure || []).some( + (n) => n.node_type === "bab" || n.node_type === "aturan" || n.node_type === "lampiran", + ); + const usePagination = (totalPasalCount || 0) >= 100 && !hasBABs; const structuralNodes = structure; let pasalNodes = initialPasals; const relationships = rels; - // For small documents with >30 pasals, fetch the rest + // For documents with >30 pasals not using client-side pagination, fetch the rest SSR if (!usePagination && (totalPasalCount || 0) > 30) { const { data: remaining } = await supabase .from("document_nodes") From 0939875397420e641fa7d473ef21c98c1ad09fc1 Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Sat, 28 Feb 2026 21:32:50 +0700 Subject: [PATCH 2/5] fix(reader): broaden hasStructure check to cover all structural node types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original hasBABs check only tested for bab/aturan/lampiran, but the BAB rendering path fires on any structural node (babNodes.length > 0 includes bagian and paragraf nodes too). A law with bagian-only structure and 100+ pasals would still regress under the previous check. Replace hasBABs with hasStructure = structure.length > 0 — aligns the pagination guard directly with the rendering condition. Co-authored-by: Claude --- .../app/[locale]/peraturan/[type]/[slug]/page.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx b/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx index 83de592..d3b3078 100644 --- a/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx @@ -252,13 +252,12 @@ async function LawReaderSection({ .order("id"), ]); - // Structured (BAB-based) laws must always load all pasals SSR so the BAB-grouping - // logic has the full set. Only use client-side infinite scroll for flat laws (no BABs) - // with a large pasal count. - const hasBABs = (structure || []).some( - (n) => n.node_type === "bab" || n.node_type === "aturan" || n.node_type === "lampiran", - ); - const usePagination = (totalPasalCount || 0) >= 100 && !hasBABs; + // Structured laws must always load all pasals SSR so the BAB-grouping logic has the + // full set. Only use client-side infinite scroll for flat laws (no structural nodes) + // with a large pasal count. Check all node types that trigger the tree-rendering path: + // bab, aturan, lampiran, bagian, paragraf. + const hasStructure = (structure || []).length > 0; + const usePagination = (totalPasalCount || 0) >= 100 && !hasStructure; const structuralNodes = structure; let pasalNodes = initialPasals; const relationships = rels; From 446166f6e4249abbda53421b3b22e04ba72c4648 Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Sat, 28 Feb 2026 23:58:39 +0700 Subject: [PATCH 3/5] fix: filter empty structural nodes (phantom BAB sections) Laws like UU 6/2023 (Ciptaker) wrap a full law as LAMPIRAN. The parser picks up the LAMPIRAN's table of contents as real BAB nodes, producing heading-only sections with no Pasal content in the reader. Add a lightweight parallel query fetching all parent_id values for pasals of the current work. Build structuralIdsWithPasals Set. Filter babNodes so only top-level structural nodes (BAB/aturan/lampiran) with at least one pasal directly or via a direct child section are rendered. Sub-sections (Bagian/Paragraf, parent_id != null) are kept unconditionally. Co-authored-by: Claude --- .../[locale]/peraturan/[type]/[slug]/page.tsx | 44 +++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx b/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx index d3b3078..a479ef6 100644 --- a/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx @@ -226,7 +226,7 @@ async function LawReaderSection({ const supabase = await createClient(); // Fire all initial queries in parallel (1 RTT instead of 2) - const [{ count: totalPasalCount }, { data: structure }, { data: initialPasals }, { data: rels }] = await Promise.all([ + const [{ count: totalPasalCount }, { data: structure }, { data: initialPasals }, { data: rels }, { data: pasalParentIds }] = await Promise.all([ supabase .from("document_nodes") .select("id", { count: "exact", head: true }) @@ -250,6 +250,15 @@ async function LawReaderSection({ .select("*, relationship_types(code, name_id, name_en)") .or(`source_work_id.eq.${workId},target_work_id.eq.${workId}`) .order("id"), + // Lightweight query: just parent_id values for all pasals, used to filter + // structural nodes (BABs, Bagians) that have no pasal content — these are + // typically table-of-contents entries parsed as structural nodes. + supabase + .from("document_nodes") + .select("parent_id") + .eq("work_id", workId) + .eq("node_type", "pasal") + .not("parent_id", "is", null), ]); // Structured laws must always load all pasals SSR so the BAB-grouping logic has the @@ -292,19 +301,46 @@ async function LawReaderSection({ const pageUrl = `https://pasal.id/peraturan/${type.toLowerCase()}/${slug}`; + // Build a set of structural node IDs that have at least one pasal child. + // Used to skip empty structural nodes (e.g. TOC entries parsed as BAB nodes). + const structuralIdsWithPasals = new Set( + (pasalParentIds || []).map((r) => r.parent_id).filter(Boolean), + ); + // Build tree structure - const babNodes = structuralNodes || []; + const allStructuralNodes = structuralNodes || []; const allPasals = pasalNodes || []; + // Filter out structural nodes (BABs, Bagians) that have no pasal content in the DB. + // This removes table-of-contents entries that the parser mistakenly captures as structural + // nodes — common in ratification laws (e.g. UU 6/2023) where the attached law's TOC + // appears verbatim and gets parsed as BAB markers without any associated Pasal content. + // Only top-level structural nodes (BAB / aturan / lampiran — those without a parent) are + // filtered; sub-sections (Bagian, Paragraf) are kept as-is under their parent BAB. + const babNodes = allStructuralNodes.filter((node) => { + // Keep sub-sections unconditionally — they're filtered indirectly via their parent BAB. + if (node.parent_id !== null) return true; + // For top-level structural nodes, keep only those that have at least one pasal + // directly or through any of their direct children (Bagian/Paragraf). + const childIds = new Set( + allStructuralNodes.filter((n) => n.parent_id === node.id).map((n) => n.id), + ); + return ( + structuralIdsWithPasals.has(node.id) || + [...childIds].some((id) => structuralIdsWithPasals.has(id)) + ); + }); + const mainContent = ( <> {babNodes.length > 0 ? ( babNodes.map((bab) => { - // Filter pasals for this BAB - const directPasals = allPasals.filter((p) => p.parent_id === bab.id); + // Find direct sub-sections (Bagian/Paragraf) of this BAB const subSectionIds = new Set( babNodes.filter((n) => n.parent_id === bab.id).map((n) => n.id), ); + // Filter pasals for this BAB + const directPasals = allPasals.filter((p) => p.parent_id === bab.id); const nestedPasals = allPasals.filter( (p) => subSectionIds.has(p.parent_id ?? -1), ); From 35f080ffc36068e3e316f93cc4f052db80dbc9fd Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Sun, 1 Mar 2026 00:13:32 +0700 Subject: [PATCH 4/5] fix: handle arbitrarily deep BAB nesting in phantom-BAB filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous filter only checked direct children of each top-level BAB node when determining whether it had pasal content. This missed the BAB → Bagian → Paragraf → Pasal nesting depth documented in the schema, causing those BABs to be silently filtered out. Replace with a parent→children map + recursive hasDescendantPasal() that walks the full subtree at any depth, so a BAB is only filtered if no structural node in its entire subtree is a direct parent of a pasal. Co-authored-by: Claude --- .../[locale]/peraturan/[type]/[slug]/page.tsx | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx b/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx index a479ef6..1c355ea 100644 --- a/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx @@ -301,7 +301,7 @@ async function LawReaderSection({ const pageUrl = `https://pasal.id/peraturan/${type.toLowerCase()}/${slug}`; - // Build a set of structural node IDs that have at least one pasal child. + // Build a set of structural node IDs that have at least one pasal child (at any depth). // Used to skip empty structural nodes (e.g. TOC entries parsed as BAB nodes). const structuralIdsWithPasals = new Set( (pasalParentIds || []).map((r) => r.parent_id).filter(Boolean), @@ -311,6 +311,25 @@ async function LawReaderSection({ const allStructuralNodes = structuralNodes || []; const allPasals = pasalNodes || []; + // Pre-build a parent→children map for O(n) descendant traversal. + const structuralChildrenMap = new Map(); + for (const n of allStructuralNodes) { + if (n.parent_id !== null) { + const siblings = structuralChildrenMap.get(n.parent_id) ?? []; + siblings.push(n.id); + structuralChildrenMap.set(n.parent_id, siblings); + } + } + + /** Returns true if nodeId or any of its structural descendants has a pasal. */ + function hasDescendantPasal(nodeId: number): boolean { + if (structuralIdsWithPasals.has(nodeId)) return true; + for (const childId of structuralChildrenMap.get(nodeId) ?? []) { + if (hasDescendantPasal(childId)) return true; + } + return false; + } + // Filter out structural nodes (BABs, Bagians) that have no pasal content in the DB. // This removes table-of-contents entries that the parser mistakenly captures as structural // nodes — common in ratification laws (e.g. UU 6/2023) where the attached law's TOC @@ -320,15 +339,9 @@ async function LawReaderSection({ const babNodes = allStructuralNodes.filter((node) => { // Keep sub-sections unconditionally — they're filtered indirectly via their parent BAB. if (node.parent_id !== null) return true; - // For top-level structural nodes, keep only those that have at least one pasal - // directly or through any of their direct children (Bagian/Paragraf). - const childIds = new Set( - allStructuralNodes.filter((n) => n.parent_id === node.id).map((n) => n.id), - ); - return ( - structuralIdsWithPasals.has(node.id) || - [...childIds].some((id) => structuralIdsWithPasals.has(id)) - ); + // For top-level structural nodes, keep only those with at least one pasal at any depth + // (handles BAB → Bagian → Paragraf → Pasal nesting, not just direct children). + return hasDescendantPasal(node.id); }); const mainContent = ( From fd3244d05a387c1a94af53f61427bdea6ec161b5 Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Sun, 1 Mar 2026 00:20:51 +0700 Subject: [PATCH 5/5] fix: filter phantom LAMPIRAN child BABs by applying hasDescendantPasal to all structural nodes Remove the unconditional short-circuit that passed any structural node with a non-null parent_id through the babNodes filter. Phantom TOC-BABs inside a LAMPIRAN have parent_id = lampiran_db_id (non-null), so the guard was letting them through despite having zero pasal descendants. Applying hasDescendantPasal() to every structural node regardless of depth fixes UU 6/2023 (Cipta Kerja): the duplicated TOC BABs parsed from the LAMPIRAN TOC pages are now correctly filtered out while real BABs and their Bagian/Paragraf sub-sections remain (they ARE in structuralIdsWithPasals). Co-authored-by: Claude --- .../[locale]/peraturan/[type]/[slug]/page.tsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx b/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx index 1c355ea..4b48afd 100644 --- a/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx @@ -330,19 +330,20 @@ async function LawReaderSection({ return false; } - // Filter out structural nodes (BABs, Bagians) that have no pasal content in the DB. + // Filter out structural nodes (BABs, Bagians, etc.) that have no pasal content in the DB. // This removes table-of-contents entries that the parser mistakenly captures as structural // nodes — common in ratification laws (e.g. UU 6/2023) where the attached law's TOC // appears verbatim and gets parsed as BAB markers without any associated Pasal content. - // Only top-level structural nodes (BAB / aturan / lampiran — those without a parent) are - // filtered; sub-sections (Bagian, Paragraf) are kept as-is under their parent BAB. - const babNodes = allStructuralNodes.filter((node) => { - // Keep sub-sections unconditionally — they're filtered indirectly via their parent BAB. - if (node.parent_id !== null) return true; - // For top-level structural nodes, keep only those with at least one pasal at any depth - // (handles BAB → Bagian → Paragraf → Pasal nesting, not just direct children). - return hasDescendantPasal(node.id); - }); + // + // We apply hasDescendantPasal() to EVERY structural node regardless of depth or parent_id. + // Previously there was a short-circuit `if (node.parent_id !== null) return true` here, + // but that incorrectly passed phantom TOC-BABs that live inside a LAMPIRAN node (they have + // a non-null parent_id pointing to the lampiran, but still have zero pasal descendants). + // Real Bagian/Paragraf nodes also have non-null parent_ids but pass the check because they + // ARE in structuralIdsWithPasals (pasals are direct children). The renderer handles + // sub-section grouping via subSectionIds — it never renders a structural node independently + // unless it's the top-level BAB iteration below. + const babNodes = allStructuralNodes.filter((node) => hasDescendantPasal(node.id)); const mainContent = ( <>