Skip to content

fix(reader): load all pasals SSR for structured (BAB-based) laws#21

Open
daffaromero wants to merge 5 commits intoilhamfp:mainfrom
daffaromero:fix/structured-law-pasal-loading
Open

fix(reader): load all pasals SSR for structured (BAB-based) laws#21
daffaromero wants to merge 5 commits intoilhamfp:mainfrom
daffaromero:fix/structured-law-pasal-loading

Conversation

@daffaromero
Copy link

Problem

Structured laws like UU Cipta Kerja (186 pasals, multiple BABs) showed empty sections after the first ~30 pasals. The page uses BAB-first rendering — each chapter filters allPasals by parent_id. When only 30 pasals were loaded SSR, all BABs beyond the first few had no matching pasals and rendered empty.

Root cause: usePagination was set whenever totalPasalCount >= 100, regardless of whether the law had a BAB structure. When usePagination = true, only the initial 30 pasals are fetched SSR and the rest are loaded client-side via infinite scroll — but client-side infinite scroll appends pasals to a flat list, it never fills in the per-BAB allPasals.filter(p => p.parent_id === bab.id) logic.

Fix

Two commits:

  1. b9c067f — Skip client pagination when the law has structural nodes (BABs, aturan, lampiran); always fetch the full pasal set SSR. Flat laws (no BABs) with 100+ pasals still use infinite scroll.

  2. 0939875 — Broaden the check from hasBABs (only tested bab/aturan/lampiran) to hasStructure = structure.length > 0, which covers all node types that trigger the tree-rendering path (including bagian and paragraf). Aligns the pagination guard directly with the babNodes.length > 0 rendering condition.

How it works

// Before (broken):
const usePagination = (totalPasalCount || 0) >= 100;

// After (fixed):
const hasStructure = (structure || []).length > 0;
const usePagination = (totalPasalCount || 0) >= 100 && !hasStructure;

structure is already fetched in the initial parallel query — zero additional DB round-trips for the check itself. For structured laws, the existing SSR "fetch remaining" branch now runs unconditionally, fetching all pasals in a single work_id-scoped indexed query.

Impact

  • Structured laws (UU Cipta Kerja, etc.): All pasals now render under their correct BABs
  • Flat laws with 100+ pasals: Behavior unchanged — still use infinite scroll
  • Flat laws with <100 pasals: Behavior unchanged — still fully fetched SSR

Co-authored-by: Claude noreply@anthropic.com

daffaromero and others added 2 commits February 28, 2026 21:33
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 <noreply@anthropic.com>
…types

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 <noreply@anthropic.com>
@daffaromero
Copy link
Author

Root Cause Analysis

There were two related issues, both in LawReaderSection in apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx.


Root Cause 1: Pagination mode ignores document structure

The page has two rendering strategies:

  1. Paginated (infinite scroll): Fetch only the first 30 pasals SSR; a client component (PasalList) loads the rest on demand and appends them to a flat list.
  2. Full SSR: Fetch all pasals server-side before rendering.

The strategy was chosen by a single flag:

const usePagination = (totalPasalCount || 0) >= 100;

This kicked in for any law with 100+ pasals — including structured ones like UU Cipta Kerja (186 pasals, many BABs).

The problem is that structured laws don't use a flat list renderer. They use a BAB-first tree renderer: the code maps over babNodes (chapters), and for each BAB filters the full allPasals array by parent_id:

babNodes.map((bab) => {
  const directPasals = allPasals.filter((p) => p.parent_id === bab.id);
  const nestedPasals = allPasals.filter((p) => subSectionIds.has(p.parent_id ?? -1));
  // renders directPasals + nestedPasals under this BAB...
})

When pagination mode is on, allPasals only contains the first 30 pasals at render time. For UU Cipta Kerja, those 30 pasals might all fall under BAB I or BAB II. BABs III through XVII get allBabPasals = [] and render as empty sections. The client-side infinite scroll does eventually load the rest, but it appends to the PasalList component — it never backfills the per-BAB filter above, because that filter runs once SSR and the result is static HTML.

In short: the paginator and the BAB renderer operate on the same data array but at different points in the lifecycle. Pagination assumed a flat list; the BAB renderer assumed the full set. The assumption was never reconciled.


Root Cause 2: The initial fix used an incomplete structural check

The first commit fixed this by introducing hasBABs:

const hasBABs = (structure || []).some(
  (n) => n.node_type === "bab" || n.node_type === "aturan" || n.node_type === "lampiran",
);
const usePagination = (totalPasalCount || 0) >= 100 && !hasBABs;

This works for UU Cipta Kerja and most Indonesian laws. However, the BAB rendering path actually fires on any structural node (babNodes.length > 0), and the structure query fetches five node types: bab, aturan, bagian, paragraf, lampiran. The check only covers three of them.

A law structured with only bagian (section) or paragraf nodes and 100+ pasals would still hit the original bug — hasBABs would be false, pagination would be enabled, but babNodes.length > 0 would still route to the tree renderer with an incomplete allPasals.

The second commit tightens this to:

const hasStructure = (structure || []).length > 0;
const usePagination = (totalPasalCount || 0) >= 100 && !hasStructure;

This aligns the pagination guard exactly with the rendering condition, so they can never disagree regardless of which node types are present.


Why it wasn't caught earlier

The bug is invisible for most laws:

  • Laws with <100 pasals always fetched everything SSR, regardless of structure — no bug.
  • Flat laws (no BABs) with 100+ pasals used infinite scroll correctly — the flat renderer just appends, no structural mismatch.
  • Only large structured laws hit the intersection where both conditions were true simultaneously.

UU Cipta Kerja is one of the few laws that is both large enough (186 pasals) and has a deep BAB/Bagian structure, making it the natural test case to expose this.

@daffaromero
Copy link
Author

daffaromero commented Feb 28, 2026

Here is the fix in action @ilhamfp:
Screenshot 2026-03-01 at 00 17 13

daffaromero and others added 3 commits March 1, 2026 00:25
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…l 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 <noreply@anthropic.com>
@daffaromero daffaromero force-pushed the fix/structured-law-pasal-loading branch from 23fc3e5 to fd3244d Compare February 28, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant