Repository source: docs/plans/archive/forte_thread_selection_url_sync_step4_implementation_summary.md
Forte Thread Selection URL Sync — Step 4: Implementation Summary
Stage 1 - Server-side selection resolver
Changes:
Application.php: newresolveForteBoardSelection(array $threads, array $tagGroups, string $requestedTag, string $requestedSelected): arrayreturning{tag, selectedThreadId}— mirrorsresolveForteBoardTag()'s validate-and-fallback shape, reusesfindTagGroup()for the mismatch check.
Verification:
php -lclean.Unit-level check via
ReflectionMethodagainst six representative inputs: valid tag+thread match, mismatched tag (thread lacks the requested tag), no tag with a valid thread, nonexistent thread ID, emptyselected, and a nonexistent tag — all six returned exactly the pair Step 2's rules specify (selected wins over a mismatched tag; invalid/missing values fall back to no selection, same as today's default).
Notes:
No callers wired yet, as planned — this stage only adds the resolver.
Stage 2 - Pre-select the row and enable Reply
Changes:
renderForteBoard(): calls the Stage 1 resolver (replacing the directresolveForteBoardTag()call), passesselectedThreadIdto the page; route dispatch reads?selected=from the query string.paned_board_thread_list.php: the matching row getspaned-list-row--selected,aria-selected="true", and the tab stop (tabindex="0") when a thread is pre-selected — the "first visible row gets the tab stop" fallback only applies when nothing is selected.forte_board.php: the Reply toolbar button renders withoutdisabledwhen a thread is pre-selected.
Verification:
php -lclean.curl(no JS)/forte?selected=root-001: raw response showsclass="paned-list-row paned-list-row--selected",aria-selected="true",tabindex="0"on the matching row, and the Reply button with nodisabledattribute.curl/forte(noselected=): zeropaned-list-row--selectedoccurrences, Reply button stilldisabled— default case unaffected.Re-ran the broader interactive regression suite (New Thread dialog, Reply flow, multi-thread Like) — all still pass, zero console errors.
Notes: none identified.
Stage 3 - Pre-show the selected thread's content article
Changes:
paned_board_content_pane.php: the placeholder article getshiddenwhen a thread is pre-selected; each thread's own article'shiddenattribute becomes conditional on matchingselectedThreadIdinstead of unconditional.
Verification:
php -lclean.curl/forte?selected=root-001: the target article has nohiddenattribute, the placeholder does.curl/forte(noselected=): placeholder visible (nohidden), all 520 thread articles stillhidden— default case unaffected.Re-ran the broader interactive regression suite — all still pass, zero console errors.
Notes: none identified.
Stage 4 - Pre-highlight the requested reply
Changes:
Route dispatch reads
?created_post_id=and passes it torenderForteBoard().renderForteBoard(): validates it against the resolved selected thread's own reply posts (found while building$replyTreesByThreadId/$allPostIds— no extra query), ignoring it if it belongs to a different thread or doesn't exist; passes the validatedhighlightedPostIdto the page.paned_thread_reply_tree.php: appliespaned-highlight-newto the matching node's initial class list.
Verification:
php -lclean.curla validselected=+created_post_id=pair (a thread and one of its own replies): the target node's class list includespaned-highlight-newin the raw response.curlthe samecreated_post_idagainst a differentselected=thread: zeropaned-highlight-newoccurrences anywhere — cross-thread mismatch correctly ignored.Re-ran the broader regression suite plus the permalink round-trip test — all still pass, zero console errors; the permalink flow now gets its highlight server-side too, not just via the pre-existing client JS.
Notes: none identified.
Stage 5 - Push/replace the URL on interactive selection
Changes:
paned_board_reader.js:urlForState()gains aselectedThreadIdparam; newreplaceStateIfChanged()counterpart to the existingpushStateIfChanged(); newreadHistoryMode()(try/catch-wrappedlocalStorage.getItem("forte-board-history-mode"), validating"always"/"never", defaulting to"click-only") read once at load;syncSelectionUrlForClick()/syncSelectionUrlForStepping()wrap the push-vs-replace decision per Step 1's Option C, wired into the row-click handler, the list's arrow-key keydown handler, andstepSelection()(Prev/Next). The tag-click and sort-click URL builders now also pass through the current selection so switching tag/sort doesn't drop it from the URL.
Verification:
node --checkclean.Headless-browser test, default mode (unset preference): two clicks each push a history entry (Back returns to the prior thread); two subsequent arrow-key steps add zero history entries (
history.lengthdelta 0), confirmingreplaceState.Headless-browser test,
"always": two arrow-key steps do push (history.lengthdelta 2).Headless-browser test,
"never": two clicks push zero entries (history.lengthdelta 0) and the URL still reflects the final selection.Re-ran the broader regression suite — all still pass, zero console errors.
Notes: none identified.
Stage 6 - Restore thread selection on Back/Forward
Changes:
paned_board_reader.js: extracted the initial-load restoration block into a sharedrestoreSelectionFromUrl(scrollRowIntoView), now also resetting to the placeholder (resetContentPane()) when the current URL names no thread (or one that's hidden/nonexistent) — needed for popping all the way back to a bare/forte. Both the initial-load path and thepopstatehandler now call this same function, so their behavior can't drift apart.
Verification:
node --checkclean.Headless-browser test: clicked two different threads (content pane correctly updates each time); pressed Back — the content pane, not just the list row, correctly reverted to the first thread (this exact restoration never existed before this stage —
popstatepreviously only handled tag/sort); pressed Back again — content pane correctly reset to the placeholder with nothing selected.Re-ran the permalink round-trip and the broader regression suite — all still pass, zero console errors.
Notes: none identified.
Stage 7 - Full regression check
Changes: none (verification only, as planned).
Verification:
Flash elimination confirmed via
curl(no JS) on an existing permalink-shaped URL (selected=+created_post_id=, no query-string cache-buster aside): both the selected row and the highlighted reply are present in the raw response.Tag/sort URL syncing re-verified unaffected, and now correctly preserves the current selection across a tag click and a sort click (previously would have dropped it, since
urlForState()didn't carryselectedbefore Stage 5).Mismatch resolution re-verified precisely (not just by construction): picked a thread that genuinely lacks the
bugtag, requested?tag=bug&selected={that thread}— response shows "All Threads" asaria-selected="true"and the thread's row correctly selected, confirming tag drops in favor of the thread exactly as specified. A garbage/nonexistentselected=alongside a valid tag leaves the tag filter untouched.Timing:
/forte?selected=root-001&created_post_id=root-001averaged ~65-73ms across three runs at ~520 threads — no measurable overhead from the new server-side resolution work (it operates entirely on already-fetched data, no new queries).Full re-run of
forte_reactions(Like/Flag across threads and replies) andforte_post_permalink(root-post and reply permalinks, including the tag-filter-escape case) — all pass, zero console errors.
Notes:
This completes all 7 planned stages for
forte_thread_selection_url_sync. Forte's board now writesselected=to the URL on every interactive thread change (push on click, replace on fast keyboard/button stepping by default, adjustable via the three-modelocalStoragepreference), restores it correctly on Back/Forward, and — closing the gap that motivated the deeper half of this feature — renders the correct tag/selection/highlight state server-side for every URL that carries it, eliminating the flash for permalinks and reply redirects that predate this cycle, not just new traffic.