commit 695a44ef43a277814b263206f3495d520eefd251
parent ba5ae2347fc02867852ddc27aa079bd6b16db7f5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 29 Sep 2026 20:52:10 -0400
common: a clear screen until the first Search — the results area renders nothing until the visitor asks in this page life (a Search, Enter, a Filters Apply, a profile load, or a link that carries a query or a filter); the bar's "Press Enter or click Search to apply" line shows meanwhile; ranThisPageLife feeds a reactive searchedThisPageLife, and the dead searchExecuted state is deleted
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 57 insertions(+), 21 deletions(-)
diff --git a/common/components/SearchBar.tsx b/common/components/SearchBar.tsx
@@ -30,6 +30,7 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
groupStates,
queryDirty,
filtersDirty,
+ searchedThisPageLife,
handleResetLayers,
layersResetDisabled,
hasSubs,
@@ -79,6 +80,11 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
draftTags,
]);
+ // The line under the bar says what to do: after an edit that is not applied
+ // yet, and before the first Search of the page life, when the results area
+ // is empty and waits for it.
+ const promptApply = queryDirty || filtersDirty || !searchedThisPageLife;
+
const submit = (
<Button
type="submit"
@@ -220,12 +226,12 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
{/* One status line under the bar instead of two hints competing for room
inside it — and a SIBLING of the form, so the pinned block is never
more than the input row plus the chips. */}
- {(queryDirty || filtersDirty || hasSubs) && (
+ {(promptApply || hasSubs) && (
<p className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
{/* The site's accent, not the warning hue: an unapplied edit is
the next step, not a fault. `--brand` clears 4.5:1 on every
base's page ground (lib/brand.ts MIN_ACCENT_CONTRAST). */}
- {(queryDirty || filtersDirty) && (
+ {promptApply && (
<span className="text-brand">
Press Enter or click Search to apply
</span>
diff --git a/common/components/SearchResults.tsx b/common/components/SearchResults.tsx
@@ -55,6 +55,7 @@ const CARD_HIT_CAP = 8;
export default function SearchResults() {
const {
+ searchedThisPageLife,
hasActiveQuery,
resultGroups,
totalHits,
@@ -112,6 +113,13 @@ export default function SearchResults() {
// results" and the footer sit underneath it and cannot be reached.
const selectionBar = view !== "chart" && resultGroups.length > 0 && selectedCount > 0;
+ // A clear screen until the visitor asks: no count, no controls, no chart and
+ // no listing — the bar, the page's intro and the footer, with the bar's
+ // "Press Enter or click Search" line saying what to do. An empty Search shows
+ // every video; a link with a query or a filter shows its results on load.
+ // `resultGroups` is still computed underneath.
+ if (!searchedThisPageLife) return null;
+
return (
<section
data-testid="results-section"
diff --git a/common/components/SearchSessionContext.tsx b/common/components/SearchSessionContext.tsx
@@ -52,6 +52,7 @@ import {
} from "../lib/availability";
import { useUrlParams, writeUrlParams } from "./urlState";
import {
+ FILTER_URL_KEYS_V1,
buildShareSearchParams,
hasShareV1,
parseShareV1,
@@ -197,12 +198,15 @@ export type GroundingMode = "search" | "selection";
// export-filter parser.
const SELECTION_KEY = "ytdlp-tb:selection";
-// Whether a search has run in this page life: a commit, a profile load, or a
-// query arriving on the URL. Module state, so it survives client-side
-// navigation and resets on a reload or a new visit. A stored query is held
+// Whether the visitor has asked in this page life: a commit (Search, Enter, a
+// Filters Apply), a profile load, or a link that carries a query or a filter
+// (`urlAsks`). Module state, so it survives client-side navigation and resets
+// on a reload or a new visit. It decides two things. A stored query is held
// (see `runHeld`) only on the page life's FIRST load — the hub mounts a fresh
// provider per route, and `/` → `/ask` must keep grounding in the search the
-// visitor just ran there.
+// visitor just ran there. And the results area shows nothing until it is set
+// (`searchedThisPageLife`, its reactive copy): until the visitor asks, the page
+// is the bar, the intro and the footer.
let ranThisPageLife = false;
function loadSelection(): string[] {
@@ -257,6 +261,22 @@ function urlHasAnyFilterParam(): boolean {
return FILTER_URL_KEYS.some((k) => p.has(k));
}
+// A link that carries a query (`qt`, legacy `q` / `m`) or a filter (`tg`, the
+// share-link keys, the legacy filter keys) is the visitor asking: it runs on
+// load and shows its results, as a Search would. A video (`v`, `t`) or a
+// chart's shape (`view`, `cs`) alone is not a search.
+const ASKING_URL_KEYS: readonly string[] = [
+ "qt",
+ "q",
+ "tg",
+ ...FILTER_URL_KEYS,
+ ...FILTER_URL_KEYS_V1,
+];
+
+function urlAsks(params: URLSearchParams): boolean {
+ return ASKING_URL_KEYS.some((k) => params.has(k));
+}
+
// Resolve a stored snapshot's per-channel deltas (plus group defaults) into
// the concrete set of EXCLUDED channel names.
function snapshotToExcluded(
@@ -457,18 +477,14 @@ function useSearchSessionState() {
const [view, setView] = useState<"results" | "chart">("results");
const [chartShape, setChartShape] = useState<ChartShape | null>(null);
- // Tracks whether the user has committed at least once this session. Used
- // by the placeholder copy below — until the user has searched, we show a
- // "tip" hint rather than the "no matching videos" empty state.
- const [, setSearchExecuted] = useState<boolean>(() => {
- if (typeof window === "undefined") return true;
- const params = new URLSearchParams(window.location.search);
- return !(
- params.has("v") &&
- !params.has("qt") &&
- (params.get("q") ?? "") === ""
- );
- });
+ // The reactive copy of `ranThisPageLife`, which stays the source of truth
+ // across remounts: a mount later in the page life starts from it, and every
+ // place that sets it sets this too. False until the visitor asks, and the
+ // results area renders nothing until then. False on the server, where the
+ // module variable is never set, so the first client render matches.
+ const [searchedThisPageLife, setSearchedThisPageLife] = useState(
+ () => ranThisPageLife,
+ );
// Defer rendering of the QueryBuilder to the client. The builder's leaf
// IDs come from a module-scoped counter that's necessarily out of sync
@@ -1273,9 +1289,11 @@ function useSearchSessionState() {
setDraftRoot(resolvedRoot);
setCommittedRoot(resolvedRoot);
if (holdRestored) setRunHeld(true);
- else setSearchExecuted(true);
- if (rootFromUrl && isNodeActive(rootFromUrl)) ranThisPageLife = true;
}
+ // After `holdRestored` is decided, so a query restored beside a
+ // filter-only link is still held on this first load.
+ if (urlAsks(params)) ranThisPageLife = true;
+ setSearchedThisPageLife(ranThisPageLife);
setDraftExcludedChannels(initialExcluded);
setDraftNov(initialNov);
@@ -1388,9 +1406,9 @@ function useSearchSessionState() {
}, [persistUiCollapse]);
const commitSearch = () => {
- setSearchExecuted(true);
setRunHeld(false);
ranThisPageLife = true;
+ setSearchedThisPageLife(true);
setCommittedRoot(draftRoot);
const nextSnapshot = buildDraftSnapshot();
const current = loadStoredState() ?? emptyStoredState();
@@ -1455,6 +1473,7 @@ function useSearchSessionState() {
// Loading a profile commits it, which runs it — as it always has.
setRunHeld(false);
ranThisPageLife = true;
+ setSearchedThisPageLife(true);
applyDraftSnapshot(snapshot);
const excluded = snapshotToExcluded(
snapshot,
@@ -2040,6 +2059,9 @@ function useSearchSessionState() {
hitBatchSize,
setHitLimit,
// ── Results ──
+ // False until the visitor asks in this page life; nothing in the results
+ // area renders until then (see `ranThisPageLife`).
+ searchedThisPageLife,
hasActiveQuery,
resultGroups,
totalHits,