Repository source: docs/plans/archive/forte_board_reply_restore_step4_implementation_summary.md
Forte Board Reply Restore — Step 4: Implementation Summary
Stage 1 - Dynamic return_to tracking tag and selection
Changes:
public/assets/paned_board_reader.js: addedcomposeReturnToUrl(threadId)(builds/forteplustag/selectedquery params viaURLSearchParams, the same techniqueurlForState()already uses, kept as a separate function so existing tag/sort pushState behavior is untouched) andcurrentSelectedThreadId()(reads the currently--selectedrow, if any).setComposeTarget(threadId)now also writes the panel'sreturn_tohidden input viacomposeReturnToUrl(threadId), alongside the existingthread_id/parent_idupdates.applyFolderSelection(tag)now callssetComposeTarget(currentSelectedThreadId())after updating the URL, soreturn_topicks up a tag change even when the selected thread survives the filter (the "selection is cleared by the filter" case was already covered, since that routes throughresetContentPane()'s existingsetComposeTarget("")call).Removed the now-duplicate
currentTagFromUrl()definition (hoisted the original up next to the new helpers; only one copy remains).
Verification:
node --check public/assets/paned_board_reader.jsclean.Headless-browser test (Selenium +
chromium-browser) on/forte:return_tostarts as/forte; selecting a thread updates it to/forte?selected={id}; filtering by a tag that still includes that thread updates it to/forte?tag={tag}&selected={id}; filtering by a tag that excludes it clears back to/forte?tag={tag}(selection cleared). Zero console errors in either run.
Notes:
This only changes the panel's outgoing
return_tovalue; the server doesn't yet honor atag/selectedquery string on/fortereturns (that's Stage 2), so submitting a reply right now would still fall back to the classic behavior established inforte_board_replyuntil Stage 2 lands.
Stage 2 - Server accepts and rebuilds a safe /forte return path
Changes:
Application.php::resolveComposeReplyReturnTo(): extended to also match/fortewith an optional query string, delegating to newbuildForteBoardReturnTo(string $requestedQueryString): string.buildForteBoardReturnTo(): parses the requested query string and rebuilds the return URL from only a fixed allowlist (tagmatched against^[a-z0-9-]+$,selectedmatched against^[A-Za-z0-9._:-]+$) — the client-supplied query string is never passed through verbatim; anything absent, invalid, or unrecognized is silently dropped rather than rejecting the whole redirect, so a badtagdoesn't also cost a validselected(or vice versa).Fixed a bug this surfaced in
handleComposeReplySubmit(): the success-path$locationwas built by unconditionally appending?created_post_id=..., which produced a malformed double-?URL (e.g./forte?tag=general?created_post_id=...) whenever$returnToalready carried its own query string. Now appends with&when$returnToalready contains?,?otherwise.
Verification:
php -lclean.curl -i -X POST /compose/replymatrix:return_to=/forte?tag=general&selected=root-001→Location: /forte?tag=general&selected=root-001&created_post_id=...(correctly joined with&, not a second?);selected-only andtag-only variants both round-trip correctly; plain/fortestill works.Invalid
tag(<script>) with a validselected→ tag is dropped,selectedsurvives (/forte?selected=root-001&...), confirming per-field stripping rather than an all-or-nothing rejection.An unrecognized extra param (
evil=https://evil.example.com) is silently discarded — never reaches the rebuilt URL, confirming the allowlist approach closes that injection path.Regression checks unchanged from
forte_reply/forte_board_reply: a fully foreignreturn_toand a missingreturn_toboth still fall back to the classic/threads/{id}location; the existing/threads/{id}/fortesingle-thread return path still resolves correctly.
Notes:
The double-
?bug was only reachable oncereturn_tocould carry its own query string (as of Stage 1), so it couldn't have been hit byforte_reply/forte_board_reply's earlier, always-barereturn_tovalues — this was a genuine gap opened by this feature, not a preexisting one.
Stage 3 - Restore selection on page load
Changes:
public/assets/paned_board_reader.js: at the end of theDOMContentLoadedsetup, readsselectedfromlocation.search; if it matches a rendered, currently-visible (!row.hidden) row, calls the existingselectThread()to restore it. Tag restoration needed no client change — it was already fully server-rendered via$selectedTag/thehiddenattribute on rows (confirmed by inspectingpaned_board_thread_list.php), so this stage only needed to add the selection half.
Verification:
node --check public/assets/paned_board_reader.jsclean.Headless-browser test (Selenium +
chromium-browser): loading/forte?tag=general&selected=root-001directly shows that row selected, its content unhidden, and Reply enabled — all from a fresh page load, no interaction. Loading/forte?selected=does-not-exist-xyzleaves the default placeholder state untouched with Reply disabled and no console errors, confirming the "thread no longer exists" fallback from Step 2's core requirements.
Notes:
The
!row.hiddenguard means aselectedid for a thread that's filtered out by the currenttagis treated the same as a nonexistent one (falls back to no selection) rather than fighting the active filter — consistent with "restore only if it makes sense" rather than forcing a filter change the reader didn't ask for.
Stage 4 - Full round trip and regression verification
Changes: none (integration/regression verification only, as planned).
Verification:
Headless-browser test (Selenium +
chromium-browser) covering the full flow: filter by a tag → select a thread → open the composer and confirmreturn_tois/forte?tag=...&selected=root-001→ submit a reply → confirm the post-submit URL carries the sametag/selected→ confirm the reloaded page shows that thread selected (paned-list-row--selectedpresent) and Reply enabled. Zero console errors.curl http://127.0.0.1:8001/forte/threads/root-001/repliesafter that submission shows the new reply body, confirming the reply itself was created correctly through the full flow (not just the redirect).Edge case (Step 2's stated requirement): loading
/fortefresh with no filter/no selection yieldsreturn_to = '/forte'exactly as before this feature — no regression for the common case.Regression check: the single-thread reader's compose panel at
/threads/root-001/fortestill hasreturn_to = /threads/root-001/forte, confirmingforte_reply's return path is completely untouched by this feature.
Notes:
This completes all 4 planned stages for
forte_board_reply_restore. Together withforte_replyandforte_board_reply, Forte's reply flow now preserves reader context (selection, and on the board view, tag filter) across every reply surface.