Skip to content

Deep-link Search By mode via URL hash - #1311

Merged
calibrain merged 4 commits into
calibrain:mainfrom
nfvelten:urlsearch-searchby
Sep 5, 2026
Merged

Deep-link Search By mode via URL hash#1311
calibrain merged 4 commits into
calibrain:mainfrom
nfvelten:urlsearch-searchby

Conversation

@nfvelten

@nfvelten nfvelten commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Closes #1228.

Search By mode (General/Author/Title/Series/Manual), content type and the search query now live in the URL as a hash fragment, updated live as you search, not just parsed once on load. A shared/bookmarked link like #search_by=manual&q=foundation reopens in that exact mode with the query filled in.

Following the direction from the issue thread:

  • Hash fragment instead of query string, so it stays browser side only.
  • Live updates via history.replaceState, no history spam per keystroke.
  • Default Search By persisted in a client side cookie as fallback when there's no hash, no user accounts needed.

Tested manually against a local build (search-by switching, hash live update, deep link reload, cookie fallback when there's no hash, and hash overriding a stale cookie).

Makes the Search By target (general/author/title/series/manual) and
content type live in a #hash fragment instead of pure client state, so
users can bookmark/share a link that opens straight into a mode. The
hash updates live via history.replaceState (no history spam), and the
last-used Search By target is remembered in a client-side cookie as
the default when a link doesn't specify one.
UrlSearchBootstrapMount applies the search_by hash override once, on
mount, but queryTargets can still be missing the hashed target at that
point if the metadata search-fields fetch hasn't resolved yet. Since
the mount effect never re-runs, the override was silently dropped and
the cookie-seeded default stuck.

Add an independent effect in App.tsx that retries the override
whenever queryTargets changes, until it succeeds (or the target never
becomes valid). Kept separate from the one-shot bootstrap so the
search execution / advanced filters logic still only fires once.
@calibrain

Copy link
Copy Markdown
Owner

Hey, thank you so much for the contribution !
I took a look, and had claude summarize some of my thoughts and issues, could you take a look ?

1. Existing query-string deep links break, and the docs still document them

useUrlSearch now reads only window.location.hash (hooks/useUrlSearch.ts:51), and useSearchParams is gone. But docs/url-search-parameters.md (linked from docs/index.md) documents /?q=harry+potter, /?q=dune&content_type=audiobook, and friends — those, plus any bookmarks users already made, now load a blank app with no indication why.

The hash was the right call for the new live-reflected state, but I don't think we want to drop what already shipped. Could we parse the query string as a fallback when the hash is empty — ideally rewriting ?…#… once on load so the two don't drift — and update the doc either way?

2. Non-general Search By targets don't round-trip: the hash the app writes reopens as a blank or general search

This is the headline use case, and I think it's broken for every target except general:

  • Direct mode, author/title/isbn target. The typed value lives in searchInput, not advancedFilters.authorbuildCurrentSearchRequest only maps it onto author= at search time (App.tsx:2308). So buildUrlSearchHash writes #search_by=author&q=herbert with no author=. On reload, UrlSearchBootstrapMount.tsx:157 blanks searchInput (because the target isn't general) and then reads advancedFilters.author, which is empty — so it runs a filters-only search while the input shows "herbert" and the selector says Author.
  • Universal mode, provider-field target (series, hardcover_list). The bootstrap never passes fieldValues the way the normal dispatch path does, so #search_by=series&q=dune executes a plain general query while the selector reads Series.
  • #search_by=manual&q=foundation — the example in the PR description — runs a normal universal search rather than handleManualSearch's release flow.

I think this needs fixing on both sides: have buildUrlSearchHash emit the value under the target's own param for direct fields, and have the bootstrap route q through the active target (fieldValues for provider fields, the direct filter for direct fields, and no auto-search for manual).

3. The cookie is written from the derived fallback, not the user's choice

App.tsx:1965 persists effectiveActiveQueryTarget, which is the re-validated value, not what the user picked. Three ways that destroys the preference it's meant to protect:

  • On a cold load, before the metadata search_fields fetch resolves, queryTargets is just [general], so effectiveActiveQueryTarget collapses to general and immediately overwrites a stored author/series cookie. It self-heals once the fields arrive — but not if the tab is closed first, or the fetch fails.
  • Clicking the logo (App.tsx:2429) and logging out (App.tsx:668) both set general, wiping the stored default. That's exactly the "bookmark that lands in Manual mode" case from the issue.
  • view_series browse sets the target programmatically (App.tsx:2249), so series silently becomes a sticky default the user never chose.

Persisting only on an explicit selector change (onQueryTargetChange, App.tsx:2452 / :2519) would avoid all three.

4. replaceState fires on every keystroke

searchInput updates per character (App.tsx:2086) → memo → effect → history.replaceState (useUrlSearch.ts:93). Safari throws SecurityError past roughly 100 replaceState calls per 30 seconds, and there's no try/catch, so it surfaces as an uncaught error inside a React effect. (The "no history spam" note in the description covers pushState vs replaceState, but not call volume.) A ~300ms debounce plus a try/catch around the call should cover it.

Worth a look

5. Pasting a shared link into an already-open tab does nothing. A hash-only change doesn't reload the document and there's no hashchange listener, so nothing reacts — and then the sync effect rewrites the URL back to the current state on the next change, making the link look broken. Either listen for hashchange and re-bootstrap, or call the limitation out in the docs.

6. The queryTargets race fixes the selector but not the search. The retry effect at App.tsx:1950 re-applies search_by once targets load, but by then the bootstrap search has already run against the wrong target, and the sync effect has rewritten the hash without search_by — so a deep link whose target hasn't loaded yet quietly mutates the URL that was shared. The comment acknowledges the race; the remedy stops one step short.

Nits

  • parseUrlSearchParams lowercases search_by, but the builder writes the raw target key. Every built-in provider field key is lowercase today, so this is fine now — but a custom provider (per shelfmark/metadata_providers/README.md) with a camelCase field key would silently never match.
  • The @example block in parseUrlSearchParams.ts still shows /?q=harry+potter style URLs, which are now hash-only.
  • A cookie is sent on every HTTP request; localStorage is a better fit for a purely client-side UI preference. Not a blocker — just flagging since "cookie" in the issue thread was probably shorthand for "client-side".

Review follow-ups on the deep-link work.

Round-tripping: the hash carried `searchInput`, but that only holds the value
for general/direct/text targets - a provider field like `series` keeps it in
searchFieldValues, so `#search_by=series&q=dune` reopened as a plain general
query with the selector reading Series. The hash now carries the active
target's value as `q`, and the bootstrap routes it back the way the live
dispatch does: direct fields into the search input and their own filter slot,
provider fields as fieldValues, and `manual` fills the input without
auto-searching (it opens the release browser from an explicit submit).

Legacy links: `?q=…` query strings shipped before the hash and are documented,
so they're still read when the hash is empty, and rewritten to `#…` once on
load so the two can't drift.

Stored default: the cookie was written from the re-validated
`effectiveActiveQueryTarget`, which collapses to `general` on a cold load
before the search fields resolve, on the logo reset, on logout, and is set
programmatically by a `view_series` browse - each of which quietly destroyed
the user's stored preference. It's now written only when the user picks a
target in the selector, and lives in localStorage rather than a cookie so it
isn't sent on every request.

Write volume: `replaceState` fired per keystroke, and Safari throws
SecurityError past ~100 calls per 30s. Debounced to 300ms and wrapped in
try/catch.

Also: a pasted link now re-bootstraps via `hashchange` instead of doing
nothing; the bootstrap waits for the search fields to settle so a deep link
can't run against the wrong target and then rewrite the shared hash without
`search_by`; and `search_by` keeps its casing, matched case-insensitively
against the live targets, so a custom provider's camelCase field key resolves.
@calibrain
calibrain merged commit 46d21ca into calibrain:main Sep 5, 2026
15 checks passed
@calibrain

Copy link
Copy Markdown
Owner

thank you so much !

@nfvelten

nfvelten commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

@calibrain happy to help, I use Shelfmark pretty much daily so this one was for me too. Thanks for the quick merge.

One thing I noticed while testing that wasn't in your review: the hash picks up the default filters, so a plain author search ends up as #q=herbert&search_by=author&sort=relevance&lang=default&format=epub&format=mobi&format=azw3&format=fb2&format=djvu&format=cbz&format=cbr. Shared links get long fast, and my own comment in the builder claims a default-state URL carries no hash at all, which isn't true. Want a small follow up that omits filters still sitting at their defaults?

@calibrain

Copy link
Copy Markdown
Owner

sure, no use keeping empty / default values in the url !

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.

Feature request: URL/deep-link support for Search By mode (e.g. Manual)

2 participants