Skip to content

Bug: beforeLoad* hook throws before prefetchInfiniteQuery runs #89

@olliethedev

Description

@olliethedev

Bug: 500 error when beforeLoad* hook throws before prefetchInfiniteQuery runs (empty query cache causes fresh SSR fetch)

Problem

When a consumer's beforeLoad* hook (e.g. beforeLoadPosts) throws an error — instead of letting the request reach the API — the plugin's createPostsLoader catch block fires before prefetchInfiniteQuery has run. The query cache is left empty for that key.

On the next step, React 18 streaming SSR renders the component tree. useSuspenseInfiniteQuery finds no cached data/error and initiates a fresh SSR fetch. This fresh fetch returns a 403/401 from the backend. In streaming SSR, errors thrown during a subsequent Suspense boundary resolution can escape client-side ErrorBoundaries and bubble up to Next.js as an unhandled error, producing a 500 Internal Server Error page instead of a graceful error UI.

Root cause

In createPostsLoader (and the equivalent loaders in other plugins) the query key variables are constructed inside the try block, after the hook invocation:

try {
  if (hooks?.beforeLoadPosts) {
    await runClientHookWithShim(...)   // ← throws here
  }

  const client = createApiClient(...)
  const queries = createBlogQueryKeys(...)
  const listQuery = queries.posts.list(...)  // never reached

  await queryClient.prefetchInfiniteQuery({ ...listQuery, initialPageParam: 0 })
  // ↑ also never reached — cache stays empty
} catch (error) {
  if (isConnectionError(error)) { ... }
  if (hooks?.onLoadError) {
    await hooks.onLoadError(error as Error, context)
  }
  // cache still empty — component will trigger a fresh SSR fetch
}

Because listQuery is never built, the catch block cannot write anything to the cache. useSuspenseInfiniteQuery then suspends and triggers a network request during streaming, which returns an error that bypasses the ErrorBoundary.

Expected behaviour

If a beforeLoad* hook throws, the error should be stored in the query cache under the same query key the component will use. useSuspenseInfiniteQuery then finds the cached error, throws it synchronously, and the ComposedRoute ErrorBoundary renders its fallback UI — no fresh network request, no 500.

Reproduction

Configure a beforeLoadPosts hook that throws for unauthenticated users:

blogClientPlugin({
  hooks: {
    beforeLoadPosts: async (filter, context) => {
      if (!filter.published) {
        const session = await getSession(context);
        if (!session) throw new Error("Unauthorized to view drafts");
      }
    },
    onLoadError(error) {
      redirect("/p/auth/sign-in"); // or display an error
    },
  },
})

Then navigate to /blog/drafts without a session. Expected: redirect or error UI. Actual: 500.

Proposed fix

Move the query key construction outside the try block (before the hook call) so it is always available in the catch block. When a non-connection error is caught, write it into the cache:

// Build query references before the try so catch can always populate the cache
const client = createApiClient<BlogApiRouter>({ baseURL: apiBaseURL, basePath: apiBasePath });
const queries = createBlogQueryKeys(client, headers);
const listQuery = queries.posts.list({ query: undefined, limit, published });

try {
  if (hooks?.beforeLoadPosts) {
    await runClientHookWithShim(...)
  }
  await queryClient.prefetchInfiniteQuery({ ...listQuery, initialPageParam: 0 })
  // ...
} catch (error) {
  if (isConnectionError(error)) {
    console.warn("[btst/blog] ...")
  } else {
    // Seed the cache with the error so useSuspenseInfiniteQuery throws it
    // to the ErrorBoundary instead of triggering a fresh (failing) SSR fetch.
    const errToStore = error instanceof Error ? error : new Error(String(error));
    await queryClient.prefetchInfiniteQuery({
      queryKey: listQuery.queryKey,
      queryFn: () => { throw errToStore; },
      initialPageParam: 0,
    });
  }
  if (hooks?.onLoadError) {
    await hooks.onLoadError(error as Error, context);
  }
}

The same pattern applies to the equivalent loaders in the CMS, Form Builder, and any other plugin that uses prefetchInfiniteQuery with a beforeLoad* hook.

Note on existing e2e tests

The existing smoke.auth-blog.spec.ts browser tests pass because they exercise a different code path: the hook in the example apps does not throw — it returns false / allows the request through, and the API itself returns 403. React Query's prefetchInfiniteQuery stores that 403 response as an error in the cache, so useSuspenseInfiniteQuery finds a cached error and the ErrorBoundary renders correctly.

The failing path (hook throws before the prefetch) is not currently covered by any browser-level e2e test. A test that configures beforeLoadPosts to throw and verifies the page shows an error state (not a 500) would prevent regressions.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions