diff --git a/apps/web/.env.example b/apps/web/.env.example
index 6a1dbbcf4..1f7beac89 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -15,6 +15,19 @@ REDIS_URL=redis://localhost:6379
NEXT_PUBLIC_WEB_URL=http://localhost:3001
NEXT_PUBLIC_API_URL=http://localhost:3001
+# The legacy Next.js application (`apps/docs`), while the migration is in
+# progress. This app owns `/` and `/discover`; `/blog/*`, `/files/*`, `/search`,
+# `/admin/*` and every plugin route are still served by Next.js on 3000, so a
+# search result pointing at one has to leave this origin to reach it. Without
+# this, `/blog/post-1` resolves against 3001 and 404s here instead.
+#
+# 3001 this app (vite dev)
+# 3000 the legacy app (apps/docs, next dev)
+#
+# Temporary: delete it, and `src/lib/legacy-app.ts`, with the last legacy route.
+# Leave it unset only if something in front of both apps routes by path.
+NEXT_PUBLIC_LEGACY_WEB_URL=http://localhost:3000
+
# === CRON Secret for Internal API Calls ===
CRON_SECRET=your-secure-cron-secret-key
diff --git a/apps/web/package.json b/apps/web/package.json
index 437a29c31..86755d359 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -7,7 +7,6 @@
},
"scripts": {
"dev": "vite dev --port 3001",
- "generate-routes": "tsr generate",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
@@ -52,12 +51,15 @@
"@tanstack/devtools-vite": "^0.8.5",
"@tanstack/eslint-config": "^0.4.0",
"@tanstack/router-cli": "^1.132.0",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/react": "^16.3.2",
"@types/node": "^22.10.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitnode/config": "workspace:*",
"eslint": "^10.7.0",
+ "jsdom": "^29.1.1",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.2",
"vite": "^8.0.0",
diff --git a/apps/web/src/components/migration-link.tsx b/apps/web/src/components/migration-link.tsx
new file mode 100644
index 000000000..16850bb30
--- /dev/null
+++ b/apps/web/src/components/migration-link.tsx
@@ -0,0 +1,123 @@
+import type { AnyRouter } from '@tanstack/react-router'
+
+import { Link, useRouter } from '@tanstack/react-router'
+
+import { useLocale } from '#/lib/i18n/client'
+import { localeRouting } from '#/lib/i18n/shared'
+import { buildLegacyHref, legacyWebOrigin } from '#/lib/legacy-app'
+
+/**
+ * Linking to a VitNode page while half of VitNode still runs on Next.js.
+ *
+ * This app owns three routes today - `/`, `/discover` and the `/api/*` mount -
+ * and search results point at all of the ones it does not: `/blog/post-30`,
+ * `/files/...`, `/admin/...`, whatever a plugin indexed. Handing every
+ * internal-looking path to `` routes those into *this* router, which has
+ * nothing to match them with, so a perfectly good blog post becomes a TanStack
+ * not-found page. During a strangler migration a full document load to the
+ * running Next.js app is the correct answer, not a fallback.
+ *
+ * So: ask the router what it owns, and let it answer.
+ *
+ * owned -> , client-side navigation, locale prefix from the rewrite
+ * not owned -> , document navigation, locale prefix applied here
+ *
+ * This is deliberately not a cross-framework navigation system, and there is no
+ * hand-maintained table of migrated routes - the route tree *is* the table. When
+ * `/blog` is migrated it appears in the generated tree, `isTanStackOwnedPath`
+ * starts answering `true` for it, and nothing here changes.
+ */
+
+/**
+ * The API mount is not a page.
+ *
+ * `/api/$` is a real route in the generated tree - it is how Hono is mounted -
+ * so it matches, and without this a search result pointing into `/api` would be
+ * handed to the router as a client-side navigation to a route that renders
+ * nothing. Matched by route id rather than by a hardcoded pathname, so it stays
+ * correct if the mount ever moves.
+ */
+const isApiRouteId = (routeId: string): boolean =>
+ routeId === '/api' || routeId.startsWith('/api/')
+
+/**
+ * Whether this app's route tree can render `href` itself.
+ *
+ * Three things have to happen before the router is asked, and each one is a way
+ * this returned the wrong answer while it was being written:
+ *
+ * 1. **Strip the query and hash.** `matchRoutes` takes a *pathname*;
+ * `/discover?a=1` matches nothing.
+ * 2. **De-localize.** The route tree has no locale in it - that is the whole of
+ * Stage 3 - so `/pl/discover` matches nothing until the prefix comes off.
+ * 3. **Reject the API mount.** See {@link isApiRouteId}.
+ *
+ * An unmatched path resolves to the root route alone, so "something below the
+ * root matched" is the test. That also means a root-level catch-all route would
+ * make every path look owned; there is none today, and
+ * `migration-link.test.tsx` fails loudly if one appears.
+ */
+export const isTanStackOwnedPath = (
+ router: AnyRouter,
+ href: string,
+): boolean => {
+ // The same rule `rewrite.input` applies, from the same Stage 3 helper - the
+ // rewrite is `deLocalizeUrl` and nothing else, so this is one rule, not a copy.
+ const { pathname } = localeRouting.deLocalizeUrl(
+ new URL(href, 'https://vitnode.invalid'),
+ )
+
+ const matched = router
+ .matchRoutes(pathname, undefined)
+ .map((match: { routeId: string }) => match.routeId)
+ .filter((routeId: string) => routeId !== '__root__')
+
+ return matched.length > 0 && !matched.some(isApiRouteId)
+}
+
+/**
+ * A link to anywhere in VitNode, migrated or not.
+ *
+ * The two branches differ in origin as well as in mechanism, which is the whole
+ * point: a relative `/blog/post-1` from this app resolves against *this* app,
+ * so it turned a client-side not-found into a full-document not-found rather
+ * than reaching the application that owns the route.
+ *
+ * - **Owned.** `` takes the *internal* path and stays relative. The
+ * router's `rewrite.output` writes the locale prefix, so `/discover` renders
+ * as `/pl/discover` while reading Polish. Neither an origin nor a prefix is
+ * added here; either would be a duplicate.
+ * - **Not owned.** The router never sees the URL, so `buildLegacyHref` localizes
+ * it with the same Stage 3 rule and points it at the legacy origin.
+ *
+ * Search parameters and hashes survive both branches untouched.
+ */
+export const MigrationLink = ({
+ children,
+ className,
+ href,
+}: {
+ children: React.ReactNode
+ className?: string
+ href: string
+}) => {
+ const router = useRouter()
+ const locale = useLocale()
+
+ if (isTanStackOwnedPath(router, href)) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/web/src/components/route-messages.tsx b/apps/web/src/components/route-messages.tsx
new file mode 100644
index 000000000..5c0999580
--- /dev/null
+++ b/apps/web/src/components/route-messages.tsx
@@ -0,0 +1,61 @@
+import { useSuspenseQuery } from '@tanstack/react-query'
+import { IntlProvider as CoreIntlProvider } from '@vitnode/core/lib/i18n/provider'
+import { IntlProvider } from 'use-intl'
+
+import { i18n } from '#/i18n'
+import { useLocale } from '#/lib/i18n/client'
+import { intlQueryOptions } from '#/lib/i18n/query'
+
+/**
+ * The strings one route renders, scoped to that route.
+ *
+ * The root provides `core.global` and nothing else, deliberately: the merged
+ * message tree holds every plugin's AdminCP copy, and a page should ship only
+ * the branches it actually renders. This is the other half of that rule - the
+ * TanStack Start counterpart of ``, which is
+ * how the Next.js pages have always done it.
+ *
+ * ## It reads, it does not fetch
+ *
+ * `useSuspenseQuery` over the same `intlQueryOptions` the route's loader
+ * already warmed, so on the first render the entry is there and nothing
+ * suspends. A route that mounts this **must** ensure the identical options in
+ * its loader - same locale, same namespaces - or the first paint is a suspend
+ * and the strings arrive a round trip late.
+ *
+ * ## Why two providers
+ *
+ * One component, two module records. `@vitnode/core` is external to Vite's SSR
+ * pass and therefore loaded by Node, while this app's source runs through
+ * Vite's module runner - so `use-intl` imported here and `use-intl` imported
+ * inside a core component can be two records with two React contexts. The
+ * outer one covers this app's own code; the inner comes from core itself
+ * (`@vitnode/core/lib/i18n/provider`) and so is by construction the record
+ * every shared component reads. See the long note in `routes/__root.tsx`, which
+ * has the same shape for the same reason.
+ *
+ * Both get the same props from one object: two providers that disagreed would
+ * render half a page in the wrong language.
+ */
+export const RouteMessages = ({
+ children,
+ namespaces,
+}: {
+ children: React.ReactNode
+ namespaces: readonly string[]
+}) => {
+ const locale = useLocale()
+ const { data } = useSuspenseQuery(intlQueryOptions({ locale, namespaces }))
+
+ const intlProps = {
+ locale,
+ messages: data.messages,
+ timeZone: i18n.timeZone,
+ }
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/web/src/lib/i18n/client.ts b/apps/web/src/lib/i18n/client.ts
index 09ba752a1..f53ded7ae 100644
--- a/apps/web/src/lib/i18n/client.ts
+++ b/apps/web/src/lib/i18n/client.ts
@@ -11,7 +11,7 @@ import {
import type { Locale } from './shared'
-import { intlQueryOptions } from './query'
+import { intlQueryOptions, loadedIntlNamespaces } from './query'
import { localeRouting } from './shared'
/**
@@ -95,6 +95,29 @@ export const publicPathnameOf = ({
publicHref: string
}): string => new URL(publicHref, RELATIVE_BASE).pathname
+/**
+ * An internal href, written in the public shape for one language.
+ *
+ * /blog/post-30 + pl -> /pl/blog/post-30
+ * /blog/post-30 + en -> /blog/post-30
+ * /admin/users + pl -> /admin/users (an ignored path takes no prefix)
+ *
+ * For links the router will never build. Anything it *does* build gets its
+ * prefix from `rewrite.output` instead, and applying both would produce
+ * `/pl/pl/...` - so this is only for the migration boundary in
+ * `components/migration-link.tsx`, where the destination belongs to the Next.js
+ * app and the router is deliberately not involved.
+ *
+ * `localizePathname` is the same Stage 3 rule the rewrite uses and is
+ * idempotent, so an href that already carries a prefix keeps exactly one. The
+ * query string and hash are preserved.
+ */
+export const localizeHref = (href: string, locale: Locale): string => {
+ const url = localeRouting.localizeUrl(new URL(href, RELATIVE_BASE), locale)
+
+ return `${url.pathname}${url.search}${url.hash}`
+}
+
/**
* The router's half of locale routing: one route tree, two public URL shapes.
*
@@ -143,7 +166,15 @@ export const useLocale = (): Locale =>
/**
* Puts a language's messages in the cache before anything renders in it.
*
- * Failure is deliberately not fatal: the switch still happens, and the
+ * Every set the page is currently showing, not just the global one. The root
+ * provides `core.global`; a route provides whatever it renders on top of that
+ * (`RouteMessages`), and both read through `useSuspenseQuery`. Warming only the
+ * first would leave the second suspending on a key nobody had fetched - which,
+ * because the suspend is caused by a store update, cannot be deferred: the page
+ * blanks for a round trip. `loadedIntlNamespaces` answers "which sets" by
+ * reading the cache, so this stays right as more routes declare their own.
+ *
+ * Failure is deliberately not fatal: the switch still happens, and each
* provider's own query retries it. A language that cannot be fetched should
* degrade to a moment of loading, not to a switcher that appears to do nothing.
*/
@@ -151,12 +182,21 @@ const warmMessages = async (router: AnyRouter, locale: Locale) => {
const { queryClient } = router.options.context as {
queryClient?: QueryClient
}
+ if (!queryClient) return
- try {
- await queryClient?.ensureQueryData(intlQueryOptions({ locale }))
- } catch {
- /* empty */
- }
+ const current = resolveLocale(publicPathnameOf(router.latestLocation))
+
+ await Promise.all(
+ loadedIntlNamespaces(queryClient, current).map(async (namespaces) => {
+ try {
+ await queryClient.ensureQueryData(
+ intlQueryOptions({ locale, namespaces }),
+ )
+ } catch {
+ /* empty */
+ }
+ }),
+ )
}
/**
diff --git a/apps/web/src/lib/i18n/query.ts b/apps/web/src/lib/i18n/query.ts
index 83bc8d983..a9cca5b84 100644
--- a/apps/web/src/lib/i18n/query.ts
+++ b/apps/web/src/lib/i18n/query.ts
@@ -1,3 +1,5 @@
+import type { QueryClient } from '@tanstack/react-query'
+
import { queryOptions } from '@tanstack/react-query'
import { createServerFn } from '@tanstack/react-start'
@@ -10,6 +12,19 @@ import { localeRouting } from './shared'
/** The strings every page needs, whatever else it renders. */
export const GLOBAL_NAMESPACE = 'core.global'
+/** Everything a message entry's key starts with, before the language. */
+const INTL_QUERY_SCOPE = ['vitnode', 'intl'] as const
+
+/**
+ * One language's slice of the message cache.
+ *
+ * Its own function because two things need it: the key each entry is stored
+ * under, and the prefix `loadedIntlNamespaces` searches by. Spelling the prefix
+ * out twice would let a search silently stop matching the keys it is looking
+ * for.
+ */
+const intlQueryPrefix = (locale: Locale) => [...INTL_QUERY_SCOPE, locale]
+
/**
* Namespaces in a form two callers cannot spell differently.
*
@@ -172,7 +187,38 @@ export const intlQueryOptions = ({
return queryOptions({
queryFn: async () =>
await getIntlMessages({ data: { locale, namespaces: normalized } }),
- queryKey: ['vitnode', 'intl', locale, ...normalized] as const,
+ queryKey: [...intlQueryPrefix(locale), ...normalized] as const,
staleTime: Infinity,
})
}
+
+/**
+ * Every namespace set a client currently holds messages for, in one language.
+ *
+ * Read off the cache rather than declared anywhere, and that is the point: the
+ * root asks for `core.global`, a route asks for whatever it renders, and by the
+ * time somebody switches language the cache is the only place that knows which
+ * sets are on screen. A language switch has to warm *those* - warming only the
+ * global set leaves the route's provider suspending on a key nobody fetched,
+ * which blanks the page for a round trip.
+ *
+ * Falls back to the global set, so a switch made before anything has loaded
+ * still warms the one set every page needs.
+ */
+export const loadedIntlNamespaces = (
+ queryClient: QueryClient,
+ locale: Locale,
+): string[][] => {
+ const prefix = intlQueryPrefix(locale)
+ const sets = queryClient
+ .getQueryCache()
+ .findAll({ queryKey: prefix })
+ .map(({ queryKey }) =>
+ queryKey
+ .slice(prefix.length)
+ .filter((part): part is string => typeof part === 'string'),
+ )
+ .filter((namespaces) => namespaces.length > 0)
+
+ return sets.length > 0 ? sets : [[GLOBAL_NAMESPACE]]
+}
diff --git a/apps/web/src/lib/legacy-app.ts b/apps/web/src/lib/legacy-app.ts
new file mode 100644
index 000000000..228ab112d
--- /dev/null
+++ b/apps/web/src/lib/legacy-app.ts
@@ -0,0 +1,71 @@
+import type { Locale } from '#/lib/i18n/shared'
+
+import { localizeHref } from '#/lib/i18n/client'
+
+/**
+ * Where the half of VitNode that has not moved yet is actually served.
+ *
+ * Temporary migration infrastructure, and deliberately this app's own rather
+ * than `@vitnode/core`'s `CONFIG`: "there is a second, older application" is
+ * true for the length of this migration and false before and after it, so it
+ * does not belong in the permanent configuration every VitNode install carries.
+ * It is expected to be deleted with the last legacy route.
+ *
+ * It is *not* `NEXT_PUBLIC_WEB_URL`, which already means "this application's own
+ * public origin" and is stamped into cookies, SSO callbacks, password-reset
+ * links and every email VitNode sends. Reusing it here would point all of those
+ * at the wrong app.
+ *
+ * Read through a getter rather than captured at module load, matching how
+ * `CONFIG` reads its own values: the browser gets this inlined at build time
+ * (see `CLIENT_ENV_KEYS` in `vitnode-env.ts`) and the server reads the live
+ * environment, so a built server can be repointed by its host.
+ */
+export const legacyWebOrigin = (): string | undefined => {
+ const configured = process.env.NEXT_PUBLIC_LEGACY_WEB_URL
+
+ if (!configured) return undefined
+
+ try {
+ return new URL(configured).origin
+ } catch {
+ // A typo in a public env var should not blank a page. Falling through to
+ // `undefined` degrades to a same-origin link, which is wrong in exactly the
+ // deployment that got the value wrong and harmless in the one that has no
+ // second origin at all.
+ return undefined
+ }
+}
+
+/**
+ * A link to a route the legacy application still owns.
+ *
+ * Pure, so the two decisions it makes are testable without a router or a DOM:
+ *
+ * 1. **Localize.** `localizeHref` is Stage 3's own rule - idempotent, and a
+ * no-op for the default locale and for paths outside the localized URL space
+ * (`/admin`). Nothing here concatenates a prefix, so `/pl/pl/...` is not a
+ * shape this can produce.
+ * 2. **Re-origin.** With `legacyOrigin` set, the localized path is resolved
+ * against it, which is what turns a document navigation into one that
+ * actually leaves this server. The query string and hash ride along.
+ *
+ * With no `legacyOrigin` the result stays relative - the pre-existing behaviour,
+ * and the right one when a proxy in front of both apps routes by path. VitNode
+ * ships no such proxy today, which is why `.env.example` sets the variable.
+ */
+export const buildLegacyHref = ({
+ href,
+ legacyOrigin,
+ locale,
+}: {
+ href: string
+ legacyOrigin?: string
+ locale: Locale
+}): string => {
+ const localized = localizeHref(href, locale)
+
+ if (!legacyOrigin) return localized
+
+ return new URL(localized, legacyOrigin).href
+}
diff --git a/apps/web/src/lib/search/discover-feed.ts b/apps/web/src/lib/search/discover-feed.ts
new file mode 100644
index 000000000..32c2feb20
--- /dev/null
+++ b/apps/web/src/lib/search/discover-feed.ts
@@ -0,0 +1,93 @@
+import type {
+ SearchFeedPageArgs,
+ SearchFeedPageFetcher,
+} from '@vitnode/core/views/search/search-feed-query'
+
+import { createIsomorphicFn } from '@tanstack/react-start'
+import {
+ fetchSearchFeedPageInBrowser,
+ searchFeedQueryKey,
+ searchFeedQueryOptions,
+} from '@vitnode/core/views/search/search-feed-query'
+
+import type { Locale } from '#/lib/i18n/shared'
+
+import { DISCOVER_FEED_PARAMS } from '#/lib/search/discover-request'
+import { fetchDiscoverFeedPageOnServer } from '#/server/discover-feed.server'
+
+/**
+ * The Discover feed, as this app's one query definition.
+ *
+ * Everything about *what* a feed page is - the request, the page size, the
+ * cursor rule, what counts as a failure - comes from
+ * `@vitnode/core/views/search/search-feed-query`, which is also what the mounted
+ * `SearchFeedContent` runs. This module supplies only the two things core cannot
+ * know: which parameters Discover browses with, and how to reach the API from a
+ * server that is rendering a request.
+ */
+
+/**
+ * The transport boundary, and the reason one query definition works in a loader
+ * and in a component.
+ *
+ * Both branches call the Hono API directly - the server one from inside the
+ * request being rendered, the browser one over the network to the same origin.
+ * There is deliberately no `createServerFn` in between: a server function is a
+ * `POST` back to this app that then calls Hono, so every scroll of the feed
+ * would cost two round trips to fetch a public, anonymous read that the API is
+ * already the boundary for.
+ *
+ * `createIsomorphicFn` is what makes that safe rather than merely tidy. The
+ * Start compiler keeps only the branch belonging to the bundle it is building
+ * and drops the other's import with it, so `discover-feed.server.ts` - and the
+ * `server-only` marker at the top of it - never reaches the browser. The client
+ * branch is core's own browser fetcher, so a hydrated page and a Next.js page
+ * fetch through exactly the same code.
+ *
+ * Un-compiled (tests, plain Node) the stub falls back to the server branch,
+ * which is the right default off a browser.
+ */
+const fetchDiscoverFeedPage: SearchFeedPageFetcher = createIsomorphicFn()
+ .server(fetchDiscoverFeedPageOnServer)
+ .client(fetchSearchFeedPageInBrowser)
+
+/**
+ * The cache entry one language's feed lives in.
+ *
+ * Core's key, not one of this app's devising. `SearchFeedContent` runs the
+ * mounted `useInfiniteQuery` and stores its pages here; a key invented locally
+ * would be a *second* entry holding the same feed, so the loader would fill one,
+ * the component would miss the other, and every visit would render a skeleton
+ * and fetch page one again from the browser.
+ *
+ * The locale is in it, which is the whole contract: `/discover` and
+ * `/pl/discover` are two feeds over two sets of documents, so they get two
+ * entries. A language switch changes the key rather than the value under it.
+ */
+export const discoverFeedQueryKey = (locale: Locale) =>
+ searchFeedQueryKey({ locale, params: DISCOVER_FEED_PARAMS })
+
+/**
+ * The Discover feed, as the one query definition every caller shares.
+ *
+ * loader: context.queryClient.ensureInfiniteQueryData(options)
+ * component:
+ * load more: fetchNextPage() // the same queryFn, cursor rule and checks
+ *
+ * No `initialData`. The loader has already put page one in the entry this key
+ * names and the SSR pass dehydrates it, so passing it again would be a second
+ * copy of the same bytes that can disagree with the first.
+ *
+ * No `staleTime` either. Freshness is whatever the API's own caching gives, plus
+ * VitNode's client defaults (`refetchOnMount` and `refetchOnWindowFocus` both
+ * off), so a hydrated feed is not refetched behind the reader. Deciding a cache
+ * lifetime belongs to the caching stage, with the API and Redis in the same view.
+ */
+export const discoverFeedQueryOptions = ({ locale }: { locale: Locale }) =>
+ searchFeedQueryOptions({
+ fetchPage: fetchDiscoverFeedPage,
+ locale,
+ params: DISCOVER_FEED_PARAMS,
+ })
+
+export type { SearchFeedPageArgs }
diff --git a/apps/web/src/lib/search/discover-request.ts b/apps/web/src/lib/search/discover-request.ts
new file mode 100644
index 000000000..a6a1a1d14
--- /dev/null
+++ b/apps/web/src/lib/search/discover-request.ts
@@ -0,0 +1,32 @@
+import type { SearchFeedParams } from '@vitnode/core/views/search/search-feed-query'
+
+/**
+ * What the Discover feed *is*, with nothing about how it travels.
+ *
+ * Two constants and no logic. Building the request, checking the response,
+ * deciding the next cursor and naming the cache entry all live in
+ * `@vitnode/core/views/search/search-feed-query`, because the mounted feed
+ * component does the same things and there must not be two answers - a loader
+ * and a component that agree only on the cache key is exactly the bug this
+ * module used to contain half of.
+ */
+
+/**
+ * Discover is the *browse* feed: no term, newest first. A term search is a
+ * different request with a different sort, and it is not this module's.
+ */
+export const DISCOVER_FEED_SORT = 'newest' as const
+
+/**
+ * Discover, as the shared feed's own parameters.
+ *
+ * One frozen module-level object, read by both halves of the route: the loader
+ * builds its cache key from it and `` is handed a query built
+ * from the same reference. Query hashes keys structurally, so an equal object
+ * would do - but one object makes it impossible for the two to drift.
+ *
+ * `search` is absent rather than empty: Discover browses, it does not query.
+ */
+export const DISCOVER_FEED_PARAMS: SearchFeedParams = Object.freeze({
+ sort: DISCOVER_FEED_SORT,
+})
diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json
index 513fa2c67..b42564635 100644
--- a/apps/web/src/locales/@vitnode/core/pl.json
+++ b/apps/web/src/locales/@vitnode/core/pl.json
@@ -6,6 +6,17 @@
"language_switcher": "Zmień język",
"save": "Zapisz",
"theme_switcher": "Zmień motyw"
+ },
+ "search": {
+ "discoverTitle": "Odkrywaj",
+ "discoverDesc": "Zobacz najnowszą aktywność w społeczności.",
+ "empty": "Nic tu jeszcze nie ma.",
+ "loadMore": "Wczytaj więcej",
+ "loading": "Wczytywanie…",
+ "types": {
+ "blog_post": "Wpis",
+ "unknown": "Treść"
+ }
}
}
}
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index 7d36ee748..343372be2 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
+import { Route as DiscoverRouteImport } from './routes/discover'
import { Route as ApiSplatRouteImport } from './routes/api/$'
const IndexRoute = IndexRouteImport.update({
@@ -17,6 +18,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
+const DiscoverRoute = DiscoverRouteImport.update({
+ id: '/discover',
+ path: '/discover',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ApiSplatRoute = ApiSplatRouteImport.update({
id: '/api/$',
path: '/api/$',
@@ -25,27 +31,31 @@ const ApiSplatRoute = ApiSplatRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
+ '/discover': typeof DiscoverRoute
'/api/$': typeof ApiSplatRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
+ '/discover': typeof DiscoverRoute
'/api/$': typeof ApiSplatRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
+ '/discover': typeof DiscoverRoute
'/api/$': typeof ApiSplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
- fullPaths: '/' | '/api/$'
+ fullPaths: '/' | '/discover' | '/api/$'
fileRoutesByTo: FileRoutesByTo
- to: '/' | '/api/$'
- id: '__root__' | '/' | '/api/$'
+ to: '/' | '/discover' | '/api/$'
+ id: '__root__' | '/' | '/discover' | '/api/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ DiscoverRoute: typeof DiscoverRoute
ApiSplatRoute: typeof ApiSplatRoute
}
@@ -58,6 +68,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/discover': {
+ id: '/discover'
+ path: '/discover'
+ fullPath: '/discover'
+ preLoaderRoute: typeof DiscoverRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/api/$': {
id: '/api/$'
path: '/api/$'
@@ -70,6 +87,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
+ DiscoverRoute: DiscoverRoute,
ApiSplatRoute: ApiSplatRoute,
}
export const routeTree = rootRouteImport
diff --git a/apps/web/src/routes/discover.tsx b/apps/web/src/routes/discover.tsx
new file mode 100644
index 000000000..ca8102247
--- /dev/null
+++ b/apps/web/src/routes/discover.tsx
@@ -0,0 +1,177 @@
+import type { SearchFeedLinkProps } from '@vitnode/core/views/search/search-feed-content'
+
+import { createFileRoute } from '@tanstack/react-router'
+import { HeaderContent } from '@vitnode/core/components/ui/header-content'
+import { formatPageTitle } from '@vitnode/core/lib/metadata'
+import { SearchFeedContent } from '@vitnode/core/views/search/search-feed-content'
+import { createTranslator } from 'use-intl'
+
+import { MigrationLink } from '#/components/migration-link'
+import { RouteMessages } from '#/components/route-messages'
+import { useLocale } from '#/lib/i18n/client'
+import { intlQueryOptions } from '#/lib/i18n/query'
+import { discoverFeedQueryOptions } from '#/lib/search/discover-feed'
+import { vitNodeShellConfig } from '#/vitnode.shell.config'
+
+/**
+ * Discover, the first VitNode route to render outside Next.js.
+ *
+ * One route file serving two public URLs. `/discover` and `/pl/discover` match
+ * *this* route: the locale is stripped before matching and written back into
+ * every link the router builds (`rewrite` in `src/router.tsx`), so nothing here
+ * mentions a language and there is no `/pl/discover.tsx` to keep in step. The
+ * Next.js route at `packages/vitnode/src/routes/main/discover/page.tsx` is still
+ * live and unchanged - this is a parallel slice until the cutover.
+ *
+ * Everything visible is shared: `HeaderContent` and `SearchFeedContent` are the
+ * same modules the Next.js app renders, with the two things a shared component
+ * cannot resolve for itself passed in - the locale, and a `Link`.
+ */
+
+/**
+ * What this page renders strings from.
+ *
+ * `core.global` is the shell's, `core.search` is the feed's - its empty state,
+ * its "load more", the label on every result type. One list, read by both the
+ * loader that fetches them and the provider that mounts them, because they have
+ * to be the same set or the provider suspends on a key nobody warmed.
+ */
+const DISCOVER_NAMESPACES = ['core.global', 'core.search'] as const
+
+/**
+ * The feed's link.
+ *
+ * `MigrationLink` rather than the router's `Link` directly, because a search
+ * result points wherever the indexed content lives and most of VitNode has not
+ * moved yet. It asks the route tree whether this app can render the
+ * destination: `/discover` is a client-side navigation, `/blog/post-30` is a
+ * document load into the Next.js app that still serves it. Either way the
+ * locale prefix is applied exactly once - by the router's rewrite on one branch
+ * and by `localizeHref` on the other.
+ *
+ * Declared at module scope rather than inline, so it is the same component type
+ * on every render and React reconciles the feed rather than remounting every
+ * result. External and unsafe URLs never reach this: `SearchFeedContent`
+ * classifies those and renders them itself.
+ */
+const DiscoverFeedLink = ({
+ children,
+ className,
+ href,
+}: SearchFeedLinkProps) => (
+
+ {children}
+
+)
+
+export const Route = createFileRoute('/discover')({
+ component: DiscoverRoute,
+ /**
+ * Both things this page needs, fetched in parallel before it renders.
+ *
+ * `context.locale` comes from the root route's `beforeLoad`, which resolved it
+ * from the public URL - so `/pl/discover` fetches Polish messages and a Polish
+ * feed, and the first byte of HTML is already in that language.
+ *
+ * Neither call is repeated by the component. The messages are read back by
+ * `RouteMessages` through the identical `intlQueryOptions`, and the feed by
+ * `SearchFeedContent` through the key `discoverFeedQueryOptions` warms - the
+ * key core itself exports for exactly this. A mismatch on either would show up
+ * as a render that starts empty and fills in a round trip later, which is the
+ * thing SSR is for.
+ *
+ * The strings the metadata needs are returned rather than looked up again:
+ * `createTranslator` is `use-intl`'s framework-free translator, over the
+ * messages just fetched.
+ */
+ loader: async ({ context }) => {
+ const [intl] = await Promise.all([
+ context.queryClient.ensureQueryData(
+ intlQueryOptions({
+ locale: context.locale,
+ namespaces: DISCOVER_NAMESPACES,
+ }),
+ ),
+ context.queryClient.ensureInfiniteQueryData(
+ discoverFeedQueryOptions({ locale: context.locale }),
+ ),
+ ])
+
+ const t = createTranslator({
+ locale: context.locale,
+ messages: intl.messages,
+ namespace: 'core.search',
+ })
+
+ return { description: t('discoverDesc'), title: t('discoverTitle') }
+ },
+ /**
+ * The page's metadata, in the language the request resolved to.
+ *
+ * **`head` must be written after `loader`.** `loaderData`'s type is inferred
+ * from `loader` in the same object literal, and TypeScript reads a literal's
+ * members in order - put `head` first and `loaderData` is `never`, while
+ * `Route.useLoaderData()` collapses to `undefined`. Neither error names the
+ * cause. It costs nothing to get right and half an hour to diagnose.
+ *
+ * `head` is synchronous here and reads the two strings out of `loaderData`,
+ * which is the smallest thing that works and the reason it is worth spelling
+ * out: `head` receives no router context, so it cannot resolve a locale, and
+ * translating inside it would mean a second lookup that could disagree with
+ * the `
`. The loader translates once; the tab title and the heading are
+ * then the same string by construction, which is exactly what the Next.js
+ * route gets from calling `getTranslations` once per request.
+ *
+ * `formatPageTitle` applies the same `" - "` rule Next.js applies
+ * through `title.template`, so both frameworks produce the same title.
+ *
+ * This is deliberately route-local. A general answer - metadata declared once
+ * and translated for every route - is a pattern this stage does not yet have
+ * enough migrated routes to design.
+ */
+ head: ({ loaderData }) => ({
+ meta: [
+ // Indexable, and stated rather than assumed: TanStack Start emits no
+ // robots directive of its own, and the Next.js route sets
+ // `robots: { index: true, follow: true }` explicitly.
+ { content: 'index, follow', name: 'robots' },
+ ...(loaderData
+ ? [
+ {
+ title: formatPageTitle(
+ vitNodeShellConfig.metadata,
+ loaderData.title,
+ ),
+ },
+ { content: loaderData.description, name: 'description' },
+ ]
+ : []),
+ ],
+ }),
+})
+
+function DiscoverRoute() {
+ const locale = useLocale()
+ const { description, title } = Route.useLoaderData()
+
+ return (
+
+
+
+
+ {/*
+ The same options object the loader ensured, so this is a cache read
+ rather than a fetch: no `initialData` and no Suspense boundary, both of
+ which would be admissions that the data is not here yet. `fetchNextPage`
+ then continues from the loader's cursor through the loader's own
+ request and status checking.
+ */}
+
+
+
+ )
+}
diff --git a/apps/web/src/server/discover-feed.server.ts b/apps/web/src/server/discover-feed.server.ts
new file mode 100644
index 000000000..b41b11caf
--- /dev/null
+++ b/apps/web/src/server/discover-feed.server.ts
@@ -0,0 +1,43 @@
+import '@tanstack/react-start/server-only'
+import type {
+ SearchFeedPageArgs,
+ SearchFeedPageFetcher,
+} from '@vitnode/core/views/search/search-feed-query'
+
+import {
+ assertSearchFeedResponse,
+ searchFeedRequest,
+ searchModuleRef,
+} from '@vitnode/core/views/search/search-feed-query'
+
+import { fetcherServer } from '#/server/fetcher.server'
+
+/**
+ * One page of the Discover feed, fetched during SSR.
+ *
+ * The request and the response check are core's - the same two the browser
+ * fetcher uses, so a page fetched here and a page fetched by `fetchNextPage()`
+ * after hydration are the same request with the same failure semantics. Only
+ * the *transport* is this module's, and it is the only part that genuinely
+ * cannot be shared.
+ *
+ * `fetcherServer` rather than a bare `fetch`: it resolves the API origin from
+ * the request being rendered - so a preview deployment calls its own hostname
+ * rather than a configured one - and forwards the visitor's cookie, user agent
+ * and `x-forwarded-for`. The feed itself is the same for everyone, but the API
+ * reads those for the rate-limit bucket and the audit IP, and a render that
+ * sends none of them puts every visitor in one bucket.
+ *
+ * Only ever reached through the isomorphic transport in
+ * `#/lib/search/discover-feed`, which is what keeps this module - and the
+ * `server-only` import above - out of the browser bundle.
+ */
+export const fetchDiscoverFeedPageOnServer: SearchFeedPageFetcher = async (
+ args: SearchFeedPageArgs,
+) => {
+ const response = await fetcherServer(searchModuleRef, searchFeedRequest(args))
+
+ assertSearchFeedResponse(response, args)
+
+ return await response.json()
+}
diff --git a/apps/web/src/server/messages.server.ts b/apps/web/src/server/messages.server.ts
index cabb19dfc..802fc106a 100644
--- a/apps/web/src/server/messages.server.ts
+++ b/apps/web/src/server/messages.server.ts
@@ -1,5 +1,6 @@
import '@tanstack/react-start/server-only'
import type { MessagesSource } from '@vitnode/core/lib/i18n/types'
+import type { AbstractIntlMessages } from 'use-intl'
import { CONFIG_PLUGIN as CORE } from '@vitnode/core/config'
import { loadMessages } from '@vitnode/core/lib/i18n/load-messages'
@@ -35,7 +36,18 @@ const sources: MessagesSource[] = [
export interface IntlMessages {
locale: string
- messages: object
+ /**
+ * The picked message tree, as `use-intl`'s own shape rather than a bare
+ * `object`.
+ *
+ * It matters at both ends. `createTranslator` constrains its messages to an
+ * indexable type, so an `object` there collapses every key it could translate
+ * to `never` - which is how a route resolves its own metadata strings. And a
+ * server function's return type has to prove itself serializable, which
+ * `Record` cannot: `unknown` might be a function. A tree of
+ * strings can.
+ */
+ messages: AbstractIntlMessages
}
/**
@@ -72,5 +84,11 @@ export const loadIntlMessages = async ({
const merged = await loadMessages({ defaultLocale, locale, sources })
- return { locale, messages: pickMessages(merged, namespaces) }
+ // `pickMessages` walks an unknown tree and cannot know what it found; what it
+ // returns is a message tree by construction, every leaf a string from a JSON
+ // file. Asserted here, once, rather than by every caller.
+ return {
+ locale,
+ messages: pickMessages(merged, namespaces) as AbstractIntlMessages,
+ }
}
diff --git a/apps/web/src/tests/discover-route.test.ts b/apps/web/src/tests/discover-route.test.ts
new file mode 100644
index 000000000..b78633fc5
--- /dev/null
+++ b/apps/web/src/tests/discover-route.test.ts
@@ -0,0 +1,630 @@
+import type { AnyRouter } from '@tanstack/react-router'
+import type { SearchFeedPage } from '@vitnode/core/views/search/types'
+
+import { QueryClient } from '@tanstack/react-query'
+import { createMemoryHistory } from '@tanstack/react-router'
+import { requestHandler } from '@tanstack/react-start/server'
+import { searchFeedRequest } from '@vitnode/core/views/search/search-feed-query'
+import { Hono } from 'hono'
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+
+import { switchLocaleOn } from '#/lib/i18n/client'
+import { intlQueryOptions } from '#/lib/i18n/query'
+import {
+ discoverFeedQueryKey,
+ discoverFeedQueryOptions,
+} from '#/lib/search/discover-feed'
+import { DISCOVER_FEED_PARAMS } from '#/lib/search/discover-request'
+import { getRouter } from '#/router'
+
+import { PLUGIN_ID } from './api-bridge-contract'
+import { renderPage } from './start-runtime/ssr-handler'
+
+/**
+ * `/discover`, over a real request, in both languages.
+ *
+ * This is Stage 4's whole claim in one file. `renderPage` drives TanStack
+ * Start's own request handler across the app's real route tree - the middleware
+ * chain in `src/start.ts`, the locale rewrite, `beforeLoad`, the route's loader,
+ * the server function that loads the messages, and React rendering to HTML - so
+ * what is asserted below is what a browser would receive. The one thing stood
+ * in for is the search API itself, which otherwise needs a database and a
+ * populated index.
+ *
+ * The assertions are grouped by the promise they keep: one route for two URLs,
+ * a feed already in the first byte of HTML, metadata in the request's language,
+ * a cache the browser can pick up without re-fetching, and a cursor to continue
+ * from.
+ */
+
+vi.setConfig({ hookTimeout: 60_000, testTimeout: 60_000 })
+
+const ORIGIN = 'https://vitnode.test'
+const at = (path: string) => new URL(path, ORIGIN).href
+const FEED_PATH = `/api/${PLUGIN_ID}/search`
+
+interface RecordedRequest {
+ path: string
+ query: Record
+}
+
+/** Every search call the stand-in API has been handed, oldest first. */
+const recorded: RecordedRequest[] = []
+
+/** Thirty documents: one full page of twenty, then a short second one. */
+const INDEXED_IDS = Array.from({ length: 30 }, (_, index) => 30 - index)
+
+/** The id the first page ends on, and therefore the cursor page two starts from. */
+const FIRST_PAGE_END_CURSOR = 11
+
+/**
+ * The newest-first walk the search index actually performs, over a synthetic
+ * index of descending row ids - and the language it was asked for, written into
+ * every title.
+ *
+ * The language in the *content* is the point: a route that fetched the default
+ * locale's feed and rendered it under a Polish heading would otherwise look
+ * identical to one that got it right.
+ */
+const answerFeed = (query: URLSearchParams): SearchFeedPage => {
+ const first = Number(query.get('first') ?? '10')
+ const cursor = query.get('cursor')
+ const lang = query.get('lang') ?? 'en'
+ const remaining =
+ cursor === null
+ ? INDEXED_IDS
+ : INDEXED_IDS.filter((id) => id < Number(cursor))
+ const page = remaining.slice(0, first)
+
+ return {
+ edges: page.map((id) => ({
+ author: null,
+ authorId: null,
+ containerId: null,
+ containerType: null,
+ content: `Body of ${lang} post ${id}`,
+ createdAt: new Date(Date.UTC(2026, 0, 1)).toISOString(),
+ id,
+ itemId: id,
+ itemType: 'blog_post',
+ languageCode: lang,
+ metadata: {},
+ pluginId: PLUGIN_ID,
+ score: null,
+ title: `Indexed ${lang} post ${id}`,
+ url: `/blog/post-${id}`,
+ })),
+ pageInfo: {
+ count: page.length,
+ endCursor: page.at(-1) ?? null,
+ hasNextPage: remaining.length > first,
+ hasPreviousPage: cursor !== null,
+ startCursor: page.at(0) ?? null,
+ totalCount: INDEXED_IDS.length,
+ },
+ }
+}
+
+/** The mounted API, stood in for at the path the real search route lives on. */
+const createSearchApi = () => {
+ const plugin = new Hono()
+
+ plugin.get('/search', (c) => {
+ const url = new URL(c.req.url)
+ recorded.push({
+ path: url.pathname,
+ query: Object.fromEntries(url.searchParams.entries()),
+ })
+
+ return c.json(answerFeed(url.searchParams))
+ })
+
+ const app = new Hono().basePath('/api')
+ app.route(`/${PLUGIN_ID}`, plugin)
+
+ return app
+}
+
+const realFetch = globalThis.fetch
+
+/** One render, with the search calls it made. */
+const renderDiscover = async (path: string) => {
+ recorded.length = 0
+ const page = await renderPage(at(path))
+
+ return { ...page, requests: [...recorded] }
+}
+
+const langOf = (html: string): string | undefined =>
+ /]*\blang="([^"]*)"/.exec(html)?.[1]
+
+const h1Of = (html: string): string | undefined =>
+ /
]*>([^<]*)
+ /]*>([^<]*)
+ new RegExp(`
+ [...html.matchAll(/href="(\/[^"]*)"/g)].map(([, href]) => href)
+
+/**
+ * Runs `handler` inside a request the way the server runtime does, so the
+ * `getRequest*` helpers `fetcherServer` reads have something to read.
+ *
+ * A rejection is carried back out rather than swallowed: `requestHandler` turns
+ * anything thrown into a 500, so a handler that failed would otherwise look to
+ * the caller like one that returned nothing.
+ */
+const withRequest = async (handler: () => Promise): Promise => {
+ let result!: T
+ let failure: undefined | { error: unknown }
+
+ await requestHandler(async () => {
+ try {
+ result = await handler()
+ } catch (error) {
+ failure = { error }
+ }
+
+ return new Response(null, { status: 204 })
+ })(new Request(at('/discover')), {})
+
+ if (failure) throw failure.error
+
+ return result
+}
+
+/**
+ * These tests boot the whole application - React, `@vitnode/core`, the plugin
+ * registry, every message file - on the first render, which under `turbo test`
+ * can outlast Vitest's default timeout and say nothing about the code. The
+ * warm-up pays that once.
+ */
+beforeAll(async () => {
+ const api = createSearchApi()
+ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) =>
+ api.fetch(new Request(input, init))
+
+ await renderPage(at('/discover'))
+})
+
+afterAll(() => {
+ globalThis.fetch = realFetch
+})
+
+describe('one route serves both public URLs', () => {
+ it('has exactly one discover route in the tree', () => {
+ // Not `/pl/discover.tsx` beside it: the rewrite strips the prefix before
+ // matching, so a second file would be a second copy of this page to keep in
+ // step. If this ever finds two, the locale has leaked into the route tree.
+ const ids = Object.keys(getRouter().routesById).filter((id) =>
+ id.includes('discover'),
+ )
+
+ expect(ids).toEqual(['/discover'])
+ })
+
+ it('answers both URLs with the page rather than a 404', async () => {
+ const [en, pl] = await Promise.all([
+ renderPage(at('/discover')),
+ renderPage(at('/pl/discover')),
+ ])
+
+ expect([en.status, pl.status]).toEqual([200, 200])
+ })
+})
+
+describe('GET /discover renders the English feed on the server', () => {
+ it('is a 200 in the default language', async () => {
+ const { html, status } = await renderDiscover('/discover')
+
+ expect(status).toBe(200)
+ expect(langOf(html)).toBe('en')
+ })
+
+ it('renders the English heading and description', async () => {
+ const { html } = await renderDiscover('/discover')
+
+ expect(h1Of(html)).toBe('Discover')
+ expect(html).toContain('See the latest activity across the community.')
+ })
+
+ it('renders the feed itself, not a placeholder for one', async () => {
+ const { html } = await renderDiscover('/discover')
+
+ // The whole first page, top and bottom - so a render that streamed in a
+ // partial page would fail here rather than pass on the first item.
+ expect(html).toContain('Indexed en post 30')
+ expect(html).toContain(`Indexed en post ${FIRST_PAGE_END_CURSOR}`)
+ expect(html).toContain('Body of en post 30')
+ // The timeline list itself, and neither of the two things "not rendered
+ // yet" looks like. The empty state is matched as *rendered text* rather
+ // than as a substring: `core.search.empty` is also one of the messages
+ // dehydrated into this document, so a bare `toContain` would always fail.
+ expect(html).toContain('')
+ expect(html).not.toContain('data-slot="skeleton"')
+ expect(html).not.toMatch(/>Nothing found yet\.)
+ })
+
+ it('asks the search API once, for the browse feed in English', async () => {
+ const { requests } = await renderDiscover('/discover')
+
+ expect(requests).toHaveLength(1)
+ expect(requests.at(0)?.path).toBe(FEED_PATH)
+ expect(requests.at(0)?.query).toStrictEqual({
+ first: '20',
+ lang: 'en',
+ sort: 'newest',
+ })
+ })
+
+ it('renders result links unprefixed on the unprefixed URL', async () => {
+ const { html } = await renderDiscover('/discover')
+
+ expect(hrefsOf(html)).toContain('/blog/post-30')
+ })
+})
+
+describe('GET /pl/discover renders the same route in Polish', () => {
+ it('is a 200 with a Polish document', async () => {
+ const { html, status } = await renderDiscover('/pl/discover')
+
+ expect(status).toBe(200)
+ expect(langOf(html)).toBe('pl')
+ })
+
+ it('renders the Polish heading and description', async () => {
+ const { html } = await renderDiscover('/pl/discover')
+
+ expect(h1Of(html)).toBe('Odkrywaj')
+ expect(html).toContain('Zobacz najnowszą aktywność w społeczności.')
+ })
+
+ it('asks the search API for the Polish feed', async () => {
+ const { requests } = await renderDiscover('/pl/discover')
+
+ expect(requests).toHaveLength(1)
+ expect(requests.at(0)?.query.lang).toBe('pl')
+ })
+
+ it('renders the Polish feed the API answered', async () => {
+ const { html } = await renderDiscover('/pl/discover')
+
+ expect(html).toContain('Indexed pl post 30')
+ expect(html).not.toContain('Indexed en post 30')
+ })
+
+ it('translates the feed’s own strings, not just the heading', async () => {
+ const { html } = await renderDiscover('/pl/discover')
+
+ // `SearchFeedContent` reads `core.search` from the provider this route
+ // mounts. Getting the heading right while this stayed English would mean the
+ // namespace never reached the tree.
+ expect(html).toContain('Wczytaj więcej')
+ expect(html).not.toContain('Load more')
+ })
+
+ it('writes the locale prefix into every result link, exactly once', async () => {
+ const { html } = await renderDiscover('/pl/discover')
+ const hrefs = hrefsOf(html)
+
+ expect(hrefs).toContain('/pl/blog/post-30')
+ // The router owns the prefix. A link that also added one by hand would show
+ // up here, and nowhere else until somebody clicked it.
+ expect(hrefs.filter((href) => href.startsWith('/pl/pl/'))).toEqual([])
+ })
+})
+
+describe('the metadata is the request’s language', () => {
+ it('titles the English page through the site’s own template', async () => {
+ const { html } = await renderDiscover('/discover')
+
+ expect(titleOf(html)).toBe('Discover - VitNode')
+ expect(metaOf(html, 'description')).toBe(
+ 'See the latest activity across the community.',
+ )
+ })
+
+ it('titles the Polish page in Polish', async () => {
+ const { html } = await renderDiscover('/pl/discover')
+
+ expect(titleOf(html)).toBe('Odkrywaj - VitNode')
+ expect(metaOf(html, 'description')).toBe(
+ 'Zobacz najnowszą aktywność w społeczności.',
+ )
+ })
+
+ it('leaves one title in the document, not the shell’s as well', async () => {
+ const { html } = await renderDiscover('/discover')
+
+ expect(html.match(/ {
+ const [en, pl] = await Promise.all([
+ renderDiscover('/discover'),
+ renderDiscover('/pl/discover'),
+ ])
+
+ expect(metaOf(en.html, 'robots')).toBe('index, follow')
+ expect(metaOf(pl.html, 'robots')).toBe('index, follow')
+ })
+
+ it('says the same thing in the tab title and in the heading', async () => {
+ // One `createTranslator` call in the loader feeds both, so they cannot drift.
+ const { html } = await renderDiscover('/pl/discover')
+
+ expect(titleOf(html)).toContain(h1Of(html))
+ })
+})
+
+/**
+ * What the browser picks up.
+ *
+ * The dehydrated cache is inlined into the document by
+ * `setupRouterSsrQueryIntegration`, so these read the payload the client will
+ * hydrate from. The query *hash* is the load-bearing part: it is the entry
+ * `SearchFeedContent` looks in when it mounts, and if the loader had warmed
+ * anything else the feed would refetch page one on hydration - an SSR page that
+ * flickers back to a skeleton in the browser.
+ */
+describe('the feed crosses to the browser in the cache, not as a refetch', () => {
+ const feedHash = (locale: string) =>
+ JSON.stringify(['search', { sort: 'newest' }, locale])
+
+ it('dehydrates the feed under the key the shared component reads', async () => {
+ const { html } = await renderDiscover('/discover')
+
+ expect(html).toContain(JSON.stringify(feedHash('en')).slice(1, -1))
+ })
+
+ it('hands it over settled, with nothing in flight', async () => {
+ const { html } = await renderDiscover('/discover')
+
+ // `fetchStatus:"idle"` plus VitNode's `refetchOnMount: false` is the whole
+ // of "the browser does not fetch this again".
+ expect(html).toContain('status:"success"')
+ expect(html).toContain('fetchStatus:"idle"')
+ })
+
+ it('dehydrates this route’s messages, not only the shell’s', async () => {
+ const { html } = await renderDiscover('/pl/discover')
+
+ expect(html).toContain(
+ JSON.stringify(
+ JSON.stringify(['vitnode', 'intl', 'pl', 'core.global', 'core.search']),
+ ).slice(1, -1),
+ )
+ })
+
+ it('keeps the two languages in separate entries', async () => {
+ const [en, pl] = await Promise.all([
+ renderDiscover('/discover'),
+ renderDiscover('/pl/discover'),
+ ])
+ const hashOf = (html: string, locale: string) =>
+ html.includes(JSON.stringify(feedHash(locale)).slice(1, -1))
+
+ expect([hashOf(en.html, 'en'), hashOf(en.html, 'pl')]).toEqual([
+ true,
+ false,
+ ])
+ expect([hashOf(pl.html, 'en'), hashOf(pl.html, 'pl')]).toEqual([
+ false,
+ true,
+ ])
+ })
+
+ it('renders the whole page from one request, however many components read it', async () => {
+ // `RouteMessages` and `SearchFeedContent` both read what the loader fetched.
+ // A second call here would mean one of them missed and fetched for itself.
+ const { requests } = await renderDiscover('/pl/discover')
+
+ expect(requests).toHaveLength(1)
+ })
+})
+
+describe('the feed can be continued from where SSR left off', () => {
+ it('offers a next page in the rendered document', async () => {
+ const { html } = await renderDiscover('/discover')
+
+ // The button only exists while `getNextPageParam` returns something, so this
+ // is the server agreeing that a cursor survived into the page.
+ expect(html).toContain('Load more')
+ expect(html).toContain(`endCursor:${FIRST_PAGE_END_CURSOR}`)
+ expect(html).toContain('hasNextPage:!0')
+ })
+
+ it('builds the second request from that cursor', () => {
+ const { args } = searchFeedRequest({
+ cursor: String(FIRST_PAGE_END_CURSOR),
+ locale: 'en',
+ params: DISCOVER_FEED_PARAMS,
+ })
+
+ expect(args.query.cursor).toBe(String(FIRST_PAGE_END_CURSOR))
+ })
+
+ it('fetches the second page and then stops', async () => {
+ recorded.length = 0
+ const queryClient = new QueryClient()
+
+ const data = await withRequest(async () =>
+ queryClient.fetchInfiniteQuery({
+ ...discoverFeedQueryOptions({ locale: 'en' }),
+ pages: 3,
+ }),
+ )
+
+ expect(recorded.at(1)?.query.cursor).toBe(String(FIRST_PAGE_END_CURSOR))
+ expect(data.pages.flatMap((page) => page.edges)).toHaveLength(30)
+ // Thirty documents is two pages, so the third fetch never happens - which
+ // is `getNextPageParam` reading the API's `hasNextPage`, not a page count
+ // guessed from the total.
+ expect(recorded).toHaveLength(2)
+ })
+})
+
+/**
+ * Switching language on the page itself, with no document reload.
+ *
+ * Driven at the router level rather than through a browser: `switchLocaleOn` is
+ * written as a plain function over a router precisely so this is testable, and
+ * `routerAt` builds the router the way `createStartHandler` does - the real route
+ * tree, the real rewrite, a memory history seeded with a public URL.
+ *
+ * A request scope wraps it because the loaders reach the API through
+ * `fetcherServer`, which reads the request being handled. In a browser the same
+ * loaders take the client branch of the isomorphic transport instead.
+ */
+describe('switching language stays on the page', () => {
+ const routerAt = (publicHref: string) => {
+ const router = getRouter()
+ router.update({
+ ...router.options,
+ history: createMemoryHistory({ initialEntries: [publicHref] }),
+ })
+
+ return router
+ }
+
+ const localeOf = (router: AnyRouter): string =>
+ (router.state.matches.at(0)?.context as { locale?: string }).locale ?? ''
+
+ const clientOf = (router: AnyRouter): QueryClient =>
+ (router.options.context as { queryClient: QueryClient }).queryClient
+
+ /**
+ * `/discover` in English, switched to Polish, without leaving the page.
+ *
+ * `added` is the order cache entries appeared in, which is the only way from
+ * outside to see *when* the switch warmed what. The message entries error in
+ * this harness - `createServerFn` needs the Start server context that only
+ * `createStartHandler` installs, and these tests drive the router directly -
+ * but an entry is created either way, and the question here is ordering rather
+ * than content. The SSR suite above is where the messages themselves are read.
+ */
+ const switchedToPolish = async () => {
+ recorded.length = 0
+
+ return withRequest(async () => {
+ const router = routerAt('/discover')
+ const added: string[] = []
+ clientOf(router)
+ .getQueryCache()
+ .subscribe((event) => {
+ if (event.type === 'added') added.push(event.query.queryHash)
+ })
+
+ await router.load()
+ const before = [...recorded]
+
+ await switchLocaleOn(router, 'pl')
+
+ return { added, after: [...recorded], before, router }
+ })
+ }
+
+ it('moves the public URL and leaves the matched route alone', async () => {
+ const { router } = await switchedToPolish()
+
+ expect(router.state.location.publicHref).toBe('/pl/discover')
+ // Internally it never moved: `/discover` and `/pl/discover` are one route,
+ // which is why the switch is an `invalidate` rather than a navigation.
+ expect(router.state.location.pathname).toBe('/discover')
+ expect(router.state.matches.at(-1)?.routeId).toBe('/discover')
+ })
+
+ it('brings the loader context in step with the new URL', async () => {
+ const { router } = await switchedToPolish()
+
+ expect(localeOf(router)).toBe('pl')
+ })
+
+ it('fetches the feed again, in the new language', async () => {
+ const { after, before } = await switchedToPolish()
+
+ expect(before.map((request) => request.query.lang)).toEqual(['en'])
+ // A different language is a different feed over different documents, so a
+ // switch that reused the English pages would be the bug, not the fetch.
+ expect(after.map((request) => request.query.lang)).toEqual(['en', 'pl'])
+ })
+
+ it('keeps both languages\u2019 feeds in the one client', async () => {
+ const { router } = await switchedToPolish()
+
+ expect(
+ clientOf(router).getQueryData(discoverFeedQueryKey('en')),
+ ).toBeDefined()
+ expect(
+ clientOf(router).getQueryData(discoverFeedQueryKey('pl')),
+ ).toBeDefined()
+ })
+
+ it('asks for every set of the new language\u2019s messages', async () => {
+ const { added } = await switchedToPolish()
+
+ for (const namespaces of [
+ ['core.global'],
+ ['core.global', 'core.search'],
+ ]) {
+ expect(added, namespaces.join()).toContain(
+ JSON.stringify(intlQueryOptions({ locale: 'pl', namespaces }).queryKey),
+ )
+ }
+ })
+
+ it('asks for them before the URL moves', async () => {
+ // The one ordering that matters. The location store updates the moment
+ // history does, so both providers re-render under the new locale while the
+ // invalidated loaders are still running - and a `useSuspenseQuery` that
+ // misses there blanks the page for a round trip. So `switchLocaleOn` warms
+ // every set the page is holding *first*, this route's included.
+ //
+ // The Polish feed is the marker for "after": only the invalidated loader
+ // fetches it, and that runs once the URL has moved.
+ const { added } = await switchedToPolish()
+ const messagesAt = added.indexOf(
+ JSON.stringify(
+ intlQueryOptions({
+ locale: 'pl',
+ namespaces: ['core.global', 'core.search'],
+ }).queryKey,
+ ),
+ )
+ const feedAt = added.indexOf(JSON.stringify(discoverFeedQueryKey('pl')))
+
+ expect(messagesAt).toBeGreaterThanOrEqual(0)
+ expect(feedAt).toBeGreaterThan(messagesAt)
+ })
+
+ it('writes the locale prefix into the links it now builds', async () => {
+ const { router } = await switchedToPolish()
+
+ expect(router.buildLocation({ to: '/discover' }).publicHref).toBe(
+ '/pl/discover',
+ )
+ })
+
+ it('switches back to the unprefixed URL', async () => {
+ recorded.length = 0
+
+ const router = await withRequest(async () => {
+ const started = routerAt('/pl/discover')
+ await started.load()
+ await switchLocaleOn(started, 'en')
+
+ return started
+ })
+
+ expect(router.state.location.publicHref).toBe('/discover')
+ expect(localeOf(router)).toBe('en')
+ expect(router.buildLocation({ to: '/discover' }).publicHref).toBe(
+ '/discover',
+ )
+ })
+})
diff --git a/apps/web/src/tests/env-plugin.test.ts b/apps/web/src/tests/env-plugin.test.ts
index 4a6f1a935..1b4479d31 100644
--- a/apps/web/src/tests/env-plugin.test.ts
+++ b/apps/web/src/tests/env-plugin.test.ts
@@ -86,6 +86,9 @@ describe('vitNodeEnv', () => {
it('inlines the API and web URLs into the client bundle', async () => {
expect(clientDefine(await runConfig(root))).toStrictEqual({
'process.env.NEXT_PUBLIC_API_URL': '"https://api.example.test"',
+ // Temporary migration infrastructure, unset in this fixture. See
+ // `src/lib/legacy-app.ts`; it goes away with the last legacy route.
+ 'process.env.NEXT_PUBLIC_LEGACY_WEB_URL': 'undefined',
'process.env.NEXT_PUBLIC_WEB_URL': '"https://web.example.test"',
})
})
@@ -120,6 +123,7 @@ describe('vitNodeEnv', () => {
// instead of falling through to the default the core config has for it.
expect(clientDefine(await runConfig(root))).toStrictEqual({
'process.env.NEXT_PUBLIC_API_URL': 'undefined',
+ 'process.env.NEXT_PUBLIC_LEGACY_WEB_URL': 'undefined',
'process.env.NEXT_PUBLIC_WEB_URL': 'undefined',
})
})
diff --git a/apps/web/src/tests/intl-query.test.ts b/apps/web/src/tests/intl-query.test.ts
index 3eca311af..40420a283 100644
--- a/apps/web/src/tests/intl-query.test.ts
+++ b/apps/web/src/tests/intl-query.test.ts
@@ -3,7 +3,11 @@ import { describe, expect, it } from 'vitest'
import type { Locale } from '#/lib/i18n/shared'
-import { GLOBAL_NAMESPACE, intlQueryOptions } from '#/lib/i18n/query'
+import {
+ GLOBAL_NAMESPACE,
+ intlQueryOptions,
+ loadedIntlNamespaces,
+} from '#/lib/i18n/query'
import { loadIntlMessages } from '#/server/messages.server'
/**
@@ -111,3 +115,68 @@ describe('two languages live in one QueryClient at once', () => {
).toBeUndefined()
})
})
+
+/**
+ * Which namespace sets a page is showing, answered by the cache.
+ *
+ * The root asks for `core.global`; a route asks for whatever it renders on top
+ * (`RouteMessages`). Nothing declares the union anywhere, so a language switch -
+ * which has to warm every set *before* the URL moves, or the second provider
+ * suspends and the page blanks - reads it back off the entries that exist.
+ */
+describe('the sets a client is holding', () => {
+ const clientHolding = (
+ entries: { locale: Locale; namespaces?: readonly string[] }[],
+ ) => {
+ const queryClient = new QueryClient()
+
+ for (const entry of entries) {
+ queryClient.setQueryData(intlQueryOptions(entry).queryKey, {
+ locale: entry.locale,
+ messages: {},
+ })
+ }
+
+ return queryClient
+ }
+
+ it('finds every set one language holds', () => {
+ const queryClient = clientHolding([
+ { locale: 'en' },
+ { locale: 'en', namespaces: [GLOBAL_NAMESPACE, 'core.search'] },
+ ])
+
+ expect(loadedIntlNamespaces(queryClient, 'en')).toEqual([
+ [GLOBAL_NAMESPACE],
+ [GLOBAL_NAMESPACE, 'core.search'],
+ ])
+ })
+
+ it('ignores the other languages’ entries', () => {
+ const queryClient = clientHolding([
+ { locale: 'en', namespaces: [GLOBAL_NAMESPACE, 'core.search'] },
+ { locale: 'pl' },
+ ])
+
+ expect(loadedIntlNamespaces(queryClient, 'pl')).toEqual([
+ [GLOBAL_NAMESPACE],
+ ])
+ })
+
+ it('ignores everything that is not a message entry', () => {
+ const queryClient = clientHolding([{ locale: 'en' }])
+ queryClient.setQueryData(['search', { sort: 'newest' }, 'en'], {})
+
+ expect(loadedIntlNamespaces(queryClient, 'en')).toEqual([
+ [GLOBAL_NAMESPACE],
+ ])
+ })
+
+ it('falls back to the global set on an empty cache', () => {
+ // A switch made before anything has loaded still has to warm the one set
+ // every page needs.
+ expect(loadedIntlNamespaces(new QueryClient(), 'en')).toEqual([
+ [GLOBAL_NAMESPACE],
+ ])
+ })
+})
diff --git a/apps/web/src/tests/isolation.test.ts b/apps/web/src/tests/isolation.test.ts
index d3d4c6912..0913d4050 100644
--- a/apps/web/src/tests/isolation.test.ts
+++ b/apps/web/src/tests/isolation.test.ts
@@ -67,7 +67,14 @@ const offendersIn = (files: string[], forbidden: string[]): string[] =>
/** Anything that only exists inside a TanStack Start app. */
const TANSTACK_ONLY = ['@tanstack/react-start', '@tanstack/react-router']
-/** Anything that only exists inside a Next.js app. */
+/**
+ * Anything that only exists inside a Next.js app.
+ *
+ * `next` covers every subpath by the prefix rule in `matches` - `next/cache`,
+ * `next/server`, `next/headers`, `next/dynamic`, `next/image`. They are not
+ * listed one by one on purpose: a list of subpaths is a list somebody has to
+ * remember to extend, and the package itself is the boundary.
+ */
const NEXT_ONLY = ['next', 'server-only']
/**
@@ -343,12 +350,17 @@ describe('the whole graph this app imports stays Next-free', () => {
/** Everything the app reaches, from every entry point it has. */
const ENTRIES = [
'apps/web/src/components/language-switcher.tsx',
+ 'apps/web/src/components/route-messages.tsx',
'apps/web/src/lib/i18n/client.ts',
'apps/web/src/lib/i18n/query.ts',
'apps/web/src/lib/i18n/shared.ts',
+ 'apps/web/src/lib/search/discover-feed.ts',
+ 'apps/web/src/lib/search/discover-request.ts',
'apps/web/src/router.tsx',
'apps/web/src/routes/__root.tsx',
+ 'apps/web/src/routes/discover.tsx',
'apps/web/src/routes/index.tsx',
+ 'apps/web/src/server/discover-feed.server.ts',
'apps/web/src/server/locale.server.ts',
'apps/web/src/server/messages.server.ts',
'apps/web/src/start.ts',
@@ -389,6 +401,71 @@ describe('the whole graph this app imports stays Next-free', () => {
it("reaches none of next-intl's Next-only entries", () => {
expect(offenders(ENTRIES, NEXT_INTL_RUNTIME)).toEqual([])
})
+
+ /**
+ * `/discover`, on its own.
+ *
+ * The first VitNode route to render outside Next.js, and the one whose graph
+ * is worth stating separately from the app's: it is the route that renders
+ * *shared* components, so it is the one that would find out - at runtime, in
+ * production - that a piece of the design system still reaches for Next's
+ * router or its request scope. It already did once: `HeaderContent` imported
+ * `@/lib/navigation`, which is `next-intl/navigation` and `next-intl/server`,
+ * and the back link it needed them for is now a prop.
+ *
+ * Every forbidden entry is asserted one at a time rather than as a set, so a
+ * failure names the specifier rather than "something in this list".
+ */
+ describe('the /discover runtime graph reaches no Next.js', () => {
+ const DISCOVER = ['apps/web/src/routes/discover.tsx']
+
+ it('walks into the shared components the route renders', () => {
+ // Without this the assertions below would pass on a graph that stopped at
+ // the route file - which is exactly the graph that cannot break.
+ const reached = [...reachableExternals(DISCOVER).visited]
+
+ expect(reached.some((path) => path.includes('search-feed-content'))).toBe(
+ true,
+ )
+ expect(reached.some((path) => path.includes('header-content'))).toBe(true)
+ })
+
+ it.each([
+ 'next',
+ 'next/cache',
+ 'next/server',
+ 'next-intl/navigation',
+ 'next-intl/server',
+ 'server-only',
+ ])('never reaches %s', (forbidden) => {
+ expect(offenders(DISCOVER, [forbidden])).toEqual([])
+ })
+
+ it('takes its translations from use-intl', () => {
+ const reached = [...reachableExternals(DISCOVER).externals.keys()]
+
+ expect(reached).toContain('use-intl')
+ })
+
+ it("only ever reaches next-intl's framework-free root entry", () => {
+ // The root entry is `use-intl` re-exported and resolves fine outside
+ // Next.js - `Button`'s client half still imports it for the loading
+ // label, and that is allowed by the same rule the app-wide scan uses.
+ // What must never appear is a subpath: those reach Next's request scope,
+ // its middleware or its build plugin, and none of them resolves here.
+ const reached = [...reachableExternals(DISCOVER).externals.keys()]
+
+ expect(reached.filter((one) => one.startsWith('next-intl/'))).toEqual([])
+ })
+
+ it('never reaches a locale-aware navigation module', () => {
+ // The one that made `HeaderContent` Next-only. `Link` now arrives as a
+ // prop, from whichever router the app happens to have.
+ const reached = [...reachableExternals(DISCOVER).externals.keys()]
+
+ expect(reached.filter((one) => one.includes('navigation'))).toEqual([])
+ })
+ })
})
/**
diff --git a/apps/web/src/tests/router-query.test.ts b/apps/web/src/tests/router-query.test.ts
index f69a63169..821f61d7d 100644
--- a/apps/web/src/tests/router-query.test.ts
+++ b/apps/web/src/tests/router-query.test.ts
@@ -109,10 +109,14 @@ describe('the Query SSR integration is installed', () => {
describe('nothing but the router creates a query client', () => {
const appFiles = [
'routes/__root.tsx',
+ 'routes/discover.tsx',
'routes/index.tsx',
+ 'components/route-messages.tsx',
'lib/i18n/client.ts',
'lib/i18n/query.ts',
'lib/i18n/shared.ts',
+ 'lib/search/discover-feed.ts',
+ 'lib/search/discover-request.ts',
]
it.each(appFiles)('%s mounts no QueryClientProvider', (file) => {
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index f0dd840a6..e4e3a5631 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -9,6 +9,19 @@ import { vitNodeEnv } from './vitnode-env'
const config = defineConfig({
resolve: { tsconfigPaths: true },
+ /**
+ * A second dev server has to fail rather than quietly move.
+ *
+ * `tanstackStart()` runs the route generator and *writes*
+ * `src/routeTree.gen.ts`. Two servers means two generators owning one file: if
+ * their route lists ever differ - which is precisely what happens when one was
+ * started before a route file existed - they overwrite each other forever, and
+ * every write is a full page reload. Without `strictPort` the second `pnpm dev`
+ * says "Port 3001 is in use, trying another one" and succeeds, so the fight
+ * starts silently and looks like an inexplicable refresh loop on the first
+ * server.
+ */
+ server: { strictPort: true },
ssr: {
/**
* The VitNode API packages mounted at `/api/*`, kept out of the SSR pass.
diff --git a/apps/web/vitnode-env.ts b/apps/web/vitnode-env.ts
index 3b6ffa978..cefc0802f 100644
--- a/apps/web/vitnode-env.ts
+++ b/apps/web/vitnode-env.ts
@@ -18,7 +18,14 @@ import { loadEnv } from 'vite'
* consequence of what somebody happened to call a variable. Add a key to publish
* one more.
*/
-const CLIENT_ENV_KEYS = ['NEXT_PUBLIC_API_URL', 'NEXT_PUBLIC_WEB_URL'] as const
+const CLIENT_ENV_KEYS = [
+ 'NEXT_PUBLIC_API_URL',
+ // Temporary, for the length of the migration: the origin serving the routes
+ // this app does not own yet. `components/migration-link.tsx` reads it in the
+ // browser, so it has to be inlined like the others. See `src/lib/legacy-app.ts`.
+ 'NEXT_PUBLIC_LEGACY_WEB_URL',
+ 'NEXT_PUBLIC_WEB_URL',
+] as const
/**
* Environment handling for a TanStack Start app that serves a VitNode API.
diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json
index 35c378087..cabf416d5 100644
--- a/packages/vitnode/package.json
+++ b/packages/vitnode/package.json
@@ -171,6 +171,7 @@
"sharp": "^0.35.3",
"tailwind-merge": "^3.6.0",
"use-debounce": "^10.1.1",
+ "use-intl": "^4.13.7",
"vaul": "^1.1.2"
}
}
diff --git a/packages/vitnode/src/components/avatar.tsx b/packages/vitnode/src/components/avatar.tsx
index 4d0e3dceb..d1aea3db8 100644
--- a/packages/vitnode/src/components/avatar.tsx
+++ b/packages/vitnode/src/components/avatar.tsx
@@ -1,5 +1,3 @@
-import Image from "next/image";
-
import { cn } from "@/lib/utils";
const generateLetterPhoto = (letter: string, color: string) =>
@@ -7,23 +5,34 @@ const generateLetterPhoto = (letter: string, color: string) =>
``,
)}`;
+/**
+ * A plain `` rather than `next/image`, on purpose.
+ *
+ * The `src` is always a `data:` URI built in this file, and Next refuses to
+ * optimize those - it marks them unoptimized and emits the same tag this does.
+ * So the import bought nothing, and it cost the whole component tree that
+ * renders an avatar its portability: the search feed, the user bar and the
+ * AdminCP tables all become Next-only the moment one of them shows a face.
+ *
+ * `loading` and `decoding` are spelled out because `next/image` set them, and a
+ * data URI is inline anyway - the browser has the bytes before it can defer.
+ */
export const Avatar = ({
user: { avatarColor, name },
className,
size,
...props
-}: Omit<
- React.ComponentProps,
- "alt" | "height" | "src" | "width"
-> & {
+}: Omit, "alt" | "height" | "src" | "width"> & {
size: number;
user: { avatarColor: string; name: string; nameCode: string };
}) => {
return (
- ,
+ "href"
+> {
+ href: string;
+}
+
+/**
+ * The one thing this header cannot decide for itself.
+ *
+ * Turning `/admin/blog` into a client-side navigation is the single question
+ * whose answer differs between the two frameworks: Next.js wants `next-intl`'s
+ * locale-aware `Link` (`@/lib/navigation`), TanStack Start wants the router's
+ * own. Both are a component taking {@link HeaderContentBackLinkProps}, so the
+ * header takes one and stops caring - and importing neither is what lets a
+ * TanStack Start route render this component at all. The same boundary
+ * `SearchFeedContent` draws for a search hit, for the same reason.
+ *
+ * It is required alongside `back` rather than defaulting to ``: a missing
+ * wrapper would otherwise degrade silently into a full document reload.
+ */
+export type HeaderContentBackLinkComponent = (
+ props: HeaderContentBackLinkProps,
+) => React.ReactNode;
+
+/**
+ * A back link, or neither half of one.
+ *
+ * Written as a union so `back` without `BackLink` is a type error at the call
+ * site. The alternative - two independent optional props - compiles, renders
+ * nothing, and looks like a header whose back button was never designed.
+ */
+type HeaderContentBackProps =
+ | { back: HeaderContentBack; BackLink: HeaderContentBackLinkComponent }
+ | { back?: never; BackLink?: never };
+
+interface HeaderContentBaseProps {
children?: React.ReactNode;
className?: string;
desc?: React.ReactNode;
ref?: React.RefCallback;
}
+export type HeaderContentProps = HeaderContentBackProps &
+ HeaderContentBaseProps &
+ (HeaderContentH1Props | HeaderContentH2Props);
+
export const HeaderContent = ({
+ BackLink,
back,
children,
className,
@@ -36,7 +83,7 @@ export const HeaderContent = ({
h1,
h2,
ref,
-}: HeaderContentH1Props | HeaderContentH2Props) => {
+}: HeaderContentProps) => {
return (
+
+
+ );
+};
+
+/**
+ * The search feed, with nothing framework-shaped left in it.
+ *
+ * This is the whole of the rendering and paging behaviour - infinite scroll, the
+ * load-more fallback, both variants, the empty and loading states - and it runs
+ * unchanged under Next.js and under TanStack Start. Exactly two things are
+ * pulled out, and they are the only two that ever needed to be:
+ *
+ * - **`queryOptions`**, built by `searchFeedQueryOptions`. There is one
+ * `useInfiniteQuery` in this file and it is handed its definition, so the page
+ * a route loader prefetched and the page `fetchNextPage()` asks for come from
+ * the same request, the same cursor rule and the same status checking. This
+ * component used to build its own, which agreed with a loader on the cache key
+ * and on nothing else - a 400 on page two was parsed as a page and the feed
+ * quietly emptied itself. The locale and the search parameters left with it,
+ * because both are things the *query* needs rather than the markup.
+ * - **`LinkComponent`**. See {@link SearchFeedLinkComponent}.
+ *
+ * Translations come from `use-intl` directly - the framework-free half of
+ * `next-intl`, and the same instance `NextIntlClientProvider` provides into, so
+ * the Next.js app needs no extra provider for this to work.
+ */
+export const SearchFeedContent = ({
+ LinkComponent,
+ queryOptions,
+ variant = "list",
+}: {
+ LinkComponent: SearchFeedLinkComponent;
+ queryOptions: SearchFeedQueryOptions;
+ variant?: SearchFeedVariant;
+}) => {
+ const t = useTranslations("core.search");
+ const sentinelRef = React.useRef(null);
+
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
+ useInfiniteQuery(queryOptions);
+
+ React.useEffect(() => {
+ const el = sentinelRef.current;
+ if (!el || !hasNextPage) return;
+
+ const observer = new IntersectionObserver(entries => {
+ if (entries[0]?.isIntersecting && !isFetchingNextPage) {
+ void fetchNextPage();
+ }
+ });
+ observer.observe(el);
+
+ return () => observer.disconnect();
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
+
+ const items = data?.pages.flatMap(page => page.edges) ?? [];
+
+ if (isLoading) {
+ return (
+
+ );
+};
diff --git a/packages/vitnode/src/views/search/search-feed-query.ts b/packages/vitnode/src/views/search/search-feed-query.ts
new file mode 100644
index 000000000..322b8f0db
--- /dev/null
+++ b/packages/vitnode/src/views/search/search-feed-query.ts
@@ -0,0 +1,264 @@
+import { infiniteQueryOptions } from "@tanstack/react-query";
+
+import type { searchModule } from "@/api/modules/search/search.module";
+
+import { CONFIG_PLUGIN } from "@/config";
+import { clientModule, fetcherClient } from "@/lib/fetcher-client";
+
+import type { SearchFeedPage } from "./types";
+
+/**
+ * The search feed, as one query definition.
+ *
+ * Everything about *fetching* a feed lives here and nowhere else: the request,
+ * the page size, the cursor rule, what counts as a failure, and the cache entry
+ * it all lands in. `SearchFeedContent` renders whatever this produces and owns
+ * none of it.
+ *
+ * That split exists because the alternative was tried and does not hold. When
+ * the component built its own `useInfiniteQuery` and a TanStack Start loader
+ * built another, the two agreed on the cache *key* and on nothing else - so the
+ * server-rendered first page came from one contract and every `fetchNextPage()`
+ * after hydration came from a second one with a different cursor rule and no
+ * status checking. A 400 arrived as `{ message }`, was read as a page, and the
+ * feed silently rendered as empty. Sharing a key is not sharing a contract.
+ *
+ * The one thing deliberately *not* fixed here is the transport: a loader running
+ * on a server and a component running in a browser cannot reach the API the same
+ * way. So {@link searchFeedQueryOptions} takes a `fetchPage` and defaults it to
+ * the browser's, which is the only one a shared component can assume.
+ */
+
+/** How many hits one page holds, wherever that page is fetched from. */
+export const SEARCH_FEED_PAGE_SIZE = 20;
+
+/**
+ * Where a page starts. `null` is the first one, spelled as the *absence* of a
+ * cursor rather than an empty one: the route's schema rejects `cursor=`
+ * outright (`.min(1)`), so sending it empty would 400 the first page of every
+ * visit.
+ */
+export type SearchFeedCursor = null | string;
+
+/** The first page carries no cursor. Named, because a test has to say so too. */
+export const SEARCH_FEED_FIRST_PAGE: SearchFeedCursor = null;
+
+export interface SearchFeedParams {
+ authorId?: string;
+ from?: string;
+ search?: string;
+ sort?: "newest" | "oldest" | "relevance";
+ to?: string;
+ types?: string;
+}
+
+/**
+ * The search module, as a value the fetchers can carry without pulling the API
+ * into either bundle. The module is imported as a *type* only, so route
+ * literals, methods and response schemas all still infer; `clientModule`
+ * supplies the one field the fetcher reads at runtime.
+ */
+export const searchModuleRef = clientModule(
+ CONFIG_PLUGIN.pluginId,
+);
+
+export interface SearchFeedPageArgs {
+ cursor: SearchFeedCursor;
+ /**
+ * The language the page is rendered in. Required rather than defaulted: a
+ * feed that quietly falls back to the default locale is a Polish page full of
+ * English posts, and nothing about the response says so.
+ */
+ locale: string;
+ params: SearchFeedParams;
+}
+
+/**
+ * One page of a feed, as arguments to whichever fetcher is carrying it.
+ *
+ * `first` is a string because the query schema reads it off a query string, and
+ * every optional key is omitted rather than set to `undefined` so it never
+ * reaches the URL at all.
+ */
+export const searchFeedRequest = ({
+ cursor,
+ locale,
+ params,
+}: SearchFeedPageArgs) => {
+ const query: Record = {
+ first: String(SEARCH_FEED_PAGE_SIZE),
+ lang: locale,
+ };
+
+ if (params.search) query.search = params.search;
+ if (params.types) query.types = params.types;
+ if (params.authorId) query.authorId = params.authorId;
+ if (params.sort) query.sort = params.sort;
+ if (params.from) query.from = params.from;
+ if (params.to) query.to = params.to;
+ if (cursor !== null) query.cursor = cursor;
+
+ return {
+ args: { query },
+ method: "get" as const,
+ module: "search" as const,
+ path: "/" as const,
+ };
+};
+
+/**
+ * Refuses a response that is not a search page.
+ *
+ * The fetchers hand non-2xx responses back rather than throwing on them - a
+ * rejected cursor is a 400, a rate-limited visitor a 429 - and `json()` would
+ * happily parse either one's `{ message }` body. Read as a page it has no
+ * `edges`, so the feed renders as empty: a failure that looks exactly like a
+ * community with nothing in it. Query can only retry, report, or keep the last
+ * good page if the promise actually rejects.
+ *
+ * A 500 never reaches here; `rawApiFetch` throws on those with the body
+ * attached. A 429 does, *after* `fetcherClient` has already raised the
+ * global rate-limit notice - so the visitor is told, and the query still fails
+ * rather than appending an error object as a page.
+ *
+ * Takes a plain `Response` so the caller keeps its typed one: passing the typed
+ * response in widens `ok` to a boolean and leaves `json()` alone.
+ */
+export const assertSearchFeedResponse = (
+ response: Response,
+ { cursor, locale }: SearchFeedPageArgs,
+): void => {
+ if (response.ok) return;
+
+ throw new Error(
+ `The search API answered ${response.status} for the feed (locale "${locale}", cursor ${cursor ?? "none"}).`,
+ );
+};
+
+/**
+ * Where the next page starts, or nothing when this was the last one.
+ *
+ * Two conditions rather than one. `hasNextPage` is the API's answer and is
+ * authoritative, but the newest-first walk cursors by row id, so an `endCursor`
+ * of `null` means there is no row to continue from - asking anyway would send
+ * `cursor=null` as a literal string and replay page one forever. Returning
+ * `undefined` is what tells Query the feed has ended, which is what turns the
+ * "load more" button off.
+ */
+export const nextSearchFeedCursor = (
+ page: SearchFeedPage,
+): SearchFeedCursor | undefined => {
+ const { endCursor, hasNextPage } = page.pageInfo;
+
+ if (!hasNextPage || endCursor === null) return undefined;
+
+ return String(endCursor);
+};
+
+/**
+ * The cache entry one feed reads and writes.
+ *
+ * `params` before `locale` is the order it has always been in; changing it
+ * would silently orphan every entry a running client already holds. The locale
+ * is *in* the key, and that is the whole contract: `/discover` and
+ * `/pl/discover` are two feeds over two sets of documents, so they get two
+ * entries.
+ *
+ * An object in a key is safe - Query hashes keys structurally rather than by
+ * identity - but only while it holds the same values, so a caller with fixed
+ * parameters should keep one module-level object rather than a literal per
+ * render.
+ */
+export const searchFeedQueryKey = ({
+ locale,
+ params,
+}: {
+ locale: string;
+ params: SearchFeedParams;
+}) => ["search", params, locale] as const;
+
+/** How a page is actually fetched. See {@link searchFeedQueryOptions}. */
+export type SearchFeedPageFetcher = (
+ args: SearchFeedPageArgs,
+) => Promise;
+
+/**
+ * One page, fetched from the browser.
+ *
+ * `fetcherClient` builds the same `/api/@vitnode/core/search` URL every other
+ * VitNode client call uses - same-origin, cookies attached by the browser
+ * itself, and a 429 routed to the global rate-limit notice.
+ */
+export const fetchSearchFeedPageInBrowser: SearchFeedPageFetcher =
+ async args => {
+ const response = await fetcherClient(
+ searchModuleRef,
+ searchFeedRequest(args),
+ );
+
+ assertSearchFeedResponse(response, args);
+
+ return await response.json();
+ };
+
+/**
+ * The feed, as the one query definition every caller shares.
+ *
+ * A route loader warms it before the component renders:
+ *
+ * context.queryClient.ensureInfiniteQueryData(searchFeedQueryOptions({...}))
+ *
+ * and the component reads the very same options back:
+ *
+ *
+ *
+ * Same key, same page function, same cursor rule - so the loader's page is the
+ * page the component renders, `fetchNextPage` continues from it under the same
+ * status checking, and no route implements paging a second time.
+ *
+ * `fetchPage` is the seam. It defaults to the browser's fetcher, which is what
+ * a Next.js client component wants and what a hydrated TanStack page wants too;
+ * an app that also fetches during SSR passes one that can do both. It is a
+ * plain async function rather than anything framework-shaped, so nothing about
+ * this module knows which framework is rendering it.
+ *
+ * `initialData` is for a server that already has page one in hand and no cache
+ * to put it in - Next.js renders the feed from a Server Component and passes it
+ * down. A framework that hydrates a real Query cache must **not** use it: the
+ * page is already in the entry this key names, and passing it again is a second
+ * copy of the same bytes that can disagree with the first.
+ *
+ * No `staleTime`. Freshness is whatever the API's own caching gives, plus
+ * VitNode's client defaults (`refetchOnMount` and `refetchOnWindowFocus` both
+ * off), so a hydrated feed is not refetched behind the reader.
+ */
+export const searchFeedQueryOptions = ({
+ fetchPage = fetchSearchFeedPageInBrowser,
+ initialData,
+ locale,
+ params,
+}: {
+ fetchPage?: SearchFeedPageFetcher;
+ initialData?: SearchFeedPage;
+ locale: string;
+ params: SearchFeedParams;
+}) =>
+ infiniteQueryOptions({
+ getNextPageParam: nextSearchFeedCursor,
+ initialData: initialData
+ ? { pageParams: [SEARCH_FEED_FIRST_PAGE], pages: [initialData] }
+ : undefined,
+ initialPageParam: SEARCH_FEED_FIRST_PAGE,
+ queryFn: async ({ pageParam }) =>
+ await fetchPage({ cursor: pageParam, locale, params }),
+ queryKey: searchFeedQueryKey({ locale, params }),
+ });
+
+/**
+ * What {@link SearchFeedContent} accepts, and the reason it accepts only this.
+ *
+ * Typed as the factory's own return type on purpose: a caller cannot hand the
+ * feed a hand-rolled options object that happens to type-check, so "one query
+ * definition" is enforced by the compiler rather than by review.
+ */
+export type SearchFeedQueryOptions = ReturnType;
diff --git a/packages/vitnode/src/views/search/search-feed.test.tsx b/packages/vitnode/src/views/search/search-feed.test.tsx
new file mode 100644
index 000000000..b65cd5adf
--- /dev/null
+++ b/packages/vitnode/src/views/search/search-feed.test.tsx
@@ -0,0 +1,183 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen } from "@testing-library/react";
+import {
+ NextIntlClientProvider,
+ IntlProvider as NextIntlIntlProvider,
+} from "next-intl";
+import React from "react";
+import { IntlProvider } from "use-intl";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { SearchFeedPage, SearchResultItem } from "./types";
+
+const fetcherClient = vi.fn();
+
+vi.mock("@/lib/fetcher-client", () => ({
+ clientModule: (pluginId: string) => ({ pluginId }),
+ fetcherClient: (...args: unknown[]) => fetcherClient(...args),
+}));
+
+/**
+ * `next-intl`'s `Link` stands in for itself here.
+ *
+ * `lib/navigation` is built on `next-intl/navigation`, which imports
+ * `next/navigation` - a bare CJS file that Next only ever resolves through its
+ * own bundler, so loading it under Vite fails outright. Mocking the module keeps
+ * this suite about the wrapper's job (resolve the locale, hand the shared feed a
+ * link) rather than about Next's router.
+ *
+ * The stand-in honours the same contract the real one does for an internal
+ * href: render an anchor at that path. That `lib/navigation` is what the wrapper
+ * actually reaches for is asserted in `feed-boundaries.test.ts`, and the real
+ * locale prefixing is covered by the docs app's end-to-end suite.
+ */
+vi.mock("@/lib/navigation", () => ({
+ Link: ({
+ children,
+ className,
+ href,
+ }: {
+ children: React.ReactNode;
+ className?: string;
+ href: string;
+ }) => (
+
+ {children}
+
+ ),
+}));
+
+const { SearchFeed } = await import("./search-feed");
+
+const messages = {
+ core: {
+ search: {
+ empty: "Nothing found yet.",
+ loadMore: "Load more",
+ loading: "Loading…",
+ types: { blog_post: "Post", unknown: "Content" },
+ },
+ },
+};
+
+const hit: SearchResultItem = {
+ author: null,
+ authorId: null,
+ containerId: null,
+ containerType: null,
+ content: "A short body.",
+ createdAt: "2026-08-01T10:00:00.000Z",
+ id: 1,
+ itemId: 1,
+ itemType: "blog_post",
+ languageCode: "en",
+ metadata: {},
+ pluginId: "@vitnode/core",
+ score: null,
+ title: "First post",
+ url: "/blog/first-post",
+};
+
+const page = (edges: SearchResultItem[]): SearchFeedPage => ({
+ edges,
+ pageInfo: {
+ count: edges.length,
+ endCursor: null,
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: null,
+ totalCount: edges.length,
+ },
+});
+
+const renderWrapper = (locale: string, initialData?: SearchFeedPage) =>
+ render(
+
+ {/* Deliberately the *Next* provider, and only it - see the suite above. */}
+
+
+
+ ,
+ );
+
+beforeEach(() => {
+ fetcherClient.mockReset();
+ vi.stubGlobal(
+ "IntersectionObserver",
+ class {
+ disconnect = vi.fn();
+ observe = vi.fn();
+ unobserve = vi.fn();
+ },
+ );
+});
+
+/**
+ * The assumption the whole split rests on.
+ *
+ * `@vitnode/core` now imports `use-intl` directly while the Next.js app still
+ * provides through `next-intl`. That only works because the two are the same
+ * module - `next-intl` re-exports `use-intl/react`'s provider verbatim - so they
+ * share one React context. Should a version bump ever give `@vitnode/core` its
+ * own copy of `use-intl`, every core component that translates would throw
+ * "No intl context found" in the Next.js app, at runtime rather than at build
+ * time. This is the cheap early warning.
+ */
+describe("core's use-intl is the same instance next-intl provides into", () => {
+ it("resolves to one provider component, not two", () => {
+ expect(IntlProvider).toBe(NextIntlIntlProvider);
+ });
+});
+
+describe("the Next wrapper", () => {
+ it("renders the shared feed under next-intl's provider alone", () => {
+ renderWrapper("en", page([hit]));
+
+ expect(screen.getByText("First post")).toBeDefined();
+ expect(screen.getByText("A short body.")).toBeDefined();
+ });
+
+ it("translates through the Next provider", () => {
+ renderWrapper("en", page([]));
+
+ expect(screen.getByText("Nothing found yet.")).toBeDefined();
+ });
+
+ it("passes the locale next-intl resolved into the search query", async () => {
+ fetcherClient.mockResolvedValue({
+ ok: true,
+ json: async () => Promise.resolve(page([hit])),
+ });
+
+ renderWrapper("pl");
+
+ await screen.findByText("First post");
+
+ const { args } = fetcherClient.mock.calls[0]?.[1] as {
+ args: { query: Record };
+ };
+
+ expect(args.query.lang).toBe("pl");
+ });
+
+ it("renders result links through next-intl's locale-aware Link", () => {
+ renderWrapper("en", page([hit]));
+
+ const link = screen.getByText("First post").closest("a");
+
+ expect(link?.dataset.nextIntlLink).toBe("true");
+ expect(link?.getAttribute("href")).toBe("/blog/first-post");
+ });
+});
diff --git a/packages/vitnode/src/views/search/search-feed.tsx b/packages/vitnode/src/views/search/search-feed.tsx
index 03a87f5c3..5756dfb42 100644
--- a/packages/vitnode/src/views/search/search-feed.tsx
+++ b/packages/vitnode/src/views/search/search-feed.tsx
@@ -1,284 +1,78 @@
"use client";
-import { useInfiniteQuery } from "@tanstack/react-query";
-import { useLocale, useTranslations } from "next-intl";
-import React from "react";
+import { useLocale } from "next-intl";
-import type { searchModule } from "@/api/modules/search/search.module";
-
-import { Avatar } from "@/components/avatar";
-import { DateFormat } from "@/components/date-format";
-import { Button } from "@/components/ui/button";
-import { Card, CardContent } from "@/components/ui/card";
-import { Skeleton } from "@/components/ui/skeleton";
-import { TooltipWithContent } from "@/components/ui/tooltip";
-import { CONFIG_PLUGIN } from "@/config";
-import { clientModule, fetcherClient } from "@/lib/fetcher-client";
import { Link } from "@/lib/navigation";
-import { cn } from "@/lib/utils";
-
-import type { SearchFeedPage, SearchResultItem } from "./types";
-
-import { getSearchTypeRenderer } from "./registry";
-
-const searchRef = clientModule(CONFIG_PLUGIN.pluginId);
-const SNIPPET_LENGTH = 240;
-
-export type SearchFeedVariant = "list" | "timeline";
-
-export interface SearchFeedParams {
- authorId?: string;
- from?: string;
- search?: string;
- sort?: "newest" | "oldest" | "relevance";
- to?: string;
- types?: string;
-}
-
-const getSnippet = (content: string): string =>
- content.length > SNIPPET_LENGTH
- ? `${content.slice(0, SNIPPET_LENGTH).trimEnd()}…`
- : content;
-
-const ItemTitle = ({ item }: { item: SearchResultItem }) =>
- item.url ? (
-
- {item.title}
-
- ) : (
- <>{item.title}>
- );
-
-const TimelineItem = ({
- item,
- isLast,
-}: {
- isLast: boolean;
- item: SearchResultItem;
-}) => {
- const t = useTranslations("core.search");
- const renderer = getSearchTypeRenderer(item.itemType);
- const Icon = renderer.icon;
- const snippet = getSnippet(item.content);
-
- const content = (
- <>
-
-
-
- );
-};
-
-const buildQuery = (
- params: SearchFeedParams,
- cursor: string,
-): Record => {
- const query: Record = { first: "20" };
- if (params.search) query.search = params.search;
- if (params.types) query.types = params.types;
- if (params.authorId) query.authorId = params.authorId;
- if (params.sort) query.sort = params.sort;
- if (params.from) query.from = params.from;
- if (params.to) query.to = params.to;
- if (cursor) query.cursor = cursor;
-
- return query;
-};
+import type {
+ SearchFeedLinkProps,
+ SearchFeedVariant,
+} from "./search-feed-content";
+import type { SearchFeedParams } from "./search-feed-query";
+import type { SearchFeedPage } from "./types";
+
+import { SearchFeedContent } from "./search-feed-content";
+import { searchFeedQueryOptions } from "./search-feed-query";
+
+export type { SearchFeedParams, SearchFeedVariant };
+
+/**
+ * The feed's link, the Next.js way: `next-intl`'s locale-aware `Link`.
+ *
+ * Declared at module scope rather than inline, so it is the same component type
+ * on every render and React reconciles rather than remounting each result.
+ */
+const NextSearchFeedLink = ({
+ children,
+ className,
+ href,
+}: SearchFeedLinkProps) => (
+
+ {children}
+
+);
+
+/**
+ * {@link SearchFeedContent}, wired to Next.js.
+ *
+ * Everything the feed does lives in the shared component; this supplies the
+ * three things that cannot be shared, and the props are unchanged, so
+ * `SearchControls`, `DiscoverView` and the AdminCP user timeline see exactly the
+ * component they always did.
+ *
+ * - **The locale**, which `next-intl` reads from Next's request scope.
+ * - **A `Link`** that knows how to write a locale prefix into an internal href.
+ * - **The query**, built here from `searchFeedQueryOptions` - the same factory
+ * a TanStack Start route loader uses, with the same request, cursor rule and
+ * status checking. Only the transport differs, and this app takes the default:
+ * the browser's fetcher, which is the right one for a client component.
+ *
+ * `initialData` stays supported because Next.js has nowhere else to put a page
+ * it already fetched: `DiscoverView` and `SearchView` render the first page in a
+ * Server Component and hand it down, with no Query cache to hydrate from. An app
+ * that *does* hydrate one must not use it - see `searchFeedQueryOptions`.
+ *
+ * The options object is rebuilt on every render, deliberately. `SearchControls`
+ * derives `params` from component state as the visitor types, so memoising on it
+ * would be memoising on a value that changes anyway - and Query hashes the key
+ * structurally, so an equal object is the same cache entry.
+ */
export const SearchFeed = ({
- params,
initialData,
+ params,
variant = "list",
}: {
initialData?: SearchFeedPage;
params: SearchFeedParams;
variant?: SearchFeedVariant;
}) => {
- const t = useTranslations("core.search");
const locale = useLocale();
- const sentinelRef = React.useRef(null);
-
- const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
- useInfiniteQuery({
- queryKey: ["search", params, locale],
- initialPageParam: "",
- queryFn: async ({ pageParam }) => {
- const res = await fetcherClient(searchRef, {
- module: "search",
- path: "/",
- method: "get",
- args: { query: { ...buildQuery(params, pageParam), lang: locale } },
- });
-
- return await res.json();
- },
- getNextPageParam: last =>
- last.pageInfo.hasNextPage ? String(last.pageInfo.endCursor) : undefined,
- initialData: initialData
- ? { pages: [initialData], pageParams: [""] }
- : undefined,
- });
-
- React.useEffect(() => {
- const el = sentinelRef.current;
- if (!el || !hasNextPage) return;
-
- const observer = new IntersectionObserver(entries => {
- if (entries[0]?.isIntersecting && !isFetchingNextPage) {
- void fetchNextPage();
- }
- });
- observer.observe(el);
-
- return () => observer.disconnect();
- }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
-
- const items = data?.pages.flatMap(page => page.edges) ?? [];
-
- if (isLoading) {
- return (
-