diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index f87c790..557a1e4 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -53,6 +53,9 @@ jobs:
name: 🚀 Deploy to production
needs: sha-guard
if: needs.sha-guard.outputs.should_deploy == 'true'
+ env:
+ CLOUDFLARE_ACCOUNT_ID:
+ ${{ vars.CLOUDFLARE_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }}
environment:
name: production
url: ${{ steps.deploy.outputs.url }}
diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml
index baa50d6..1e5d9ee 100644
--- a/.github/workflows/preview.yml
+++ b/.github/workflows/preview.yml
@@ -52,6 +52,9 @@ jobs:
deploy:
runs-on: ubuntu-latest
name: 🔎 Deploy Preview Resources
+ env:
+ CLOUDFLARE_ACCOUNT_ID:
+ ${{ vars.CLOUDFLARE_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }}
environment:
name:
preview-${{ github.event.pull_request.number || inputs.pr_number ||
@@ -433,6 +436,9 @@ jobs:
cleanup:
runs-on: ubuntu-latest
name: 🧹 Cleanup Preview Resources
+ env:
+ CLOUDFLARE_ACCOUNT_ID:
+ ${{ vars.CLOUDFLARE_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }}
if: >-
(github.event_name == 'pull_request' &&
github.event.action == 'closed' &&
diff --git a/client/app-root.tsx b/client/app-root.tsx
new file mode 100644
index 0000000..e6a8f34
--- /dev/null
+++ b/client/app-root.tsx
@@ -0,0 +1,31 @@
+import { clientEntry, type EntryComponent, type Handle } from 'remix/ui'
+import { type AppLoaderData } from '#shared/loader-data.ts'
+import { App } from './app.tsx'
+import { AppLoaderDataProvider } from './loader-data-context.tsx'
+import { RouterLocationProvider } from './router-location.tsx'
+import { type SessionInfo } from './session.ts'
+
+export const APP_ROOT_ENTRY_ID = '/client-entry.js#AppRoot'
+
+export type AppRootProps = {
+ url: string
+ session: SessionInfo | null
+ loaderData?: AppLoaderData
+ notFound?: boolean
+}
+
+export const AppRoot: EntryComponent = clientEntry(
+ APP_ROOT_ENTRY_ID,
+ function AppRoot(handle: Handle) {
+ return () => (
+
+
+
+
+
+ )
+ },
+)
diff --git a/client/app.tsx b/client/app.tsx
index b415dce..bfea0af 100644
--- a/client/app.tsx
+++ b/client/app.tsx
@@ -1,5 +1,6 @@
import { css, type Handle } from 'remix/ui'
-import { clientRoutes } from './routes/index.tsx'
+import { routes } from '#server/routes.ts'
+import { clientRouteLoaders, clientRoutes } from './routes/index.tsx'
import {
getPathname,
listenToRouterNavigation,
@@ -10,14 +11,21 @@ import {
type SessionInfo,
type SessionStatus,
} from './session.ts'
+import { SessionProvider } from './session-context.tsx'
import { buildAuthLink } from './auth-links.ts'
import { colors, mq, spacing, typography } from './styles/tokens.ts'
-export function App(handle: Handle) {
- let session: SessionInfo | null = null
- let sessionStatus: SessionStatus = 'idle'
+
+type AppProps = {
+ embeddedSession?: SessionInfo | null
+ notFound?: boolean
+}
+
+export function App(handle: Handle) {
+ let session: SessionInfo | null = handle.props?.embeddedSession ?? null
+ let sessionStatus: SessionStatus = 'ready'
let sessionRefreshInFlight = false
let sessionRefreshQueued = false
- let currentPathname = getPathname()
+ let currentPathname = getPathname(handle)
function queueSessionRefresh() {
sessionRefreshQueued = true
if (sessionRefreshInFlight) return
@@ -29,9 +37,22 @@ export function App(handle: Handle) {
sessionRefreshQueued = false
sessionRefreshInFlight = true
handle.queueTask(async (signal) => {
- const nextSession = await fetchSessionInfo(signal)
+ let nextSession: SessionInfo | null = null
+ try {
+ nextSession = await fetchSessionInfo(signal)
+ } catch (error) {
+ sessionRefreshInFlight = false
+ if (signal.aborted) return
+ console.error('Session refresh failed', error)
+ sessionStatus = 'ready'
+ handle.update()
+ return
+ }
sessionRefreshInFlight = false
- if (signal.aborted) return
+ if (signal.aborted) {
+ if (sessionRefreshQueued) queueSessionRefresh()
+ return
+ }
session = nextSession
sessionStatus = 'ready'
handle.update()
@@ -43,11 +64,13 @@ export function App(handle: Handle) {
handle.update()
}
}
- handle.queueTask(() => {
- queueSessionRefresh()
- })
+ if (typeof window !== 'undefined') {
+ handle.queueTask(() => {
+ queueSessionRefresh()
+ })
+ }
listenToRouterNavigation(handle, () => {
- currentPathname = getPathname()
+ currentPathname = getPathname(handle)
queueSessionRefresh()
handle.update()
})
@@ -85,11 +108,12 @@ export function App(handle: Handle) {
const isLoggedIn = isSessionReady && Boolean(sessionEmail)
const showAuthLinks = isSessionReady && !isLoggedIn
const oauthRedirectTo =
- typeof window !== 'undefined' && currentPathname === '/oauth/authorize'
+ typeof window !== 'undefined' &&
+ currentPathname === routes.oauthAuthorize.href()
? `${currentPathname}${window.location.search}`
: null
- const loginHref = buildAuthLink('/login', oauthRedirectTo)
- const signupHref = buildAuthLink('/signup', oauthRedirectTo)
+ const loginHref = buildAuthLink(routes.login.href(), oauthRedirectTo)
+ const signupHref = buildAuthLink(routes.signup.href(), oauthRedirectTo)
return (
-
+
-
+
Chat
-
+
{sessionEmail}
-
)
}
diff --git a/client/client-router.tsx b/client/client-router.tsx
index 451e2c0..db7c36b 100644
--- a/client/client-router.tsx
+++ b/client/client-router.tsx
@@ -1,8 +1,25 @@
import { addEventListeners, type Handle } from 'remix/ui'
+import { createMultiMatcher } from 'remix/route-pattern/match'
+import {
+ markNavigationDataStale,
+ setPreloadedNavigationData,
+} from './navigation-data.ts'
+import {
+ isRouteLoaderRedirect,
+ type RouteLoader,
+ type RouteLoaderResult,
+} from './route-loader.ts'
+import {
+ readRouterPathname,
+ readRouterUrl,
+ readSsrRouterUrl,
+} from './router-location.tsx'
type RouterSetup = {
routes: Record
fallback?: JSX.Element
+ loaders?: Record
+ notFound?: boolean
}
type FormMethod = 'get' | 'post'
@@ -56,7 +73,18 @@ type FormSubmitDetails = {
}
export const routerEvents = new EventTarget()
+const clientRouteOrigin = 'https://epicflare.local'
+const routeMatchers = new WeakMap<
+ Record,
+ ReturnType
+>()
+const routeLoaderMatchers = new WeakMap<
+ Record,
+ ReturnType
+>()
let routerInitialized = false
+let activeRouteLoaders: Record | null = null
+let pendingProgrammaticNavigationPath: string | null = null
function notify() {
routerEvents.dispatchEvent(new Event('navigate'))
@@ -73,28 +101,55 @@ function isSameOriginUrl(url: URL) {
return url.origin === window.location.origin
}
-function compileRoutePattern(pattern: string) {
- const regexPattern = pattern
- .replace(/:([^/]+)/g, '([^/]+)')
- .replace(/\*/g, '.*')
+function createRouteMatcher(routes: Record) {
+ const matcher = createMultiMatcher()
+ for (const [pattern, routeElement] of Object.entries(routes)) {
+ matcher.add(pattern, routeElement)
+ }
+ return matcher
+}
- return {
- pattern: new RegExp(`^${regexPattern}$`),
+function createRouteLoaderMatcher(loaders: Record) {
+ const matcher = createMultiMatcher()
+ for (const [pattern, loader] of Object.entries(loaders)) {
+ matcher.add(pattern, loader)
}
+ return matcher
}
-function matchRoute(
+function getRouteMatcher(routes: Record) {
+ const existing = routeMatchers.get(routes)
+ if (existing) return existing
+ const matcher = createRouteMatcher(routes)
+ routeMatchers.set(routes, matcher)
+ return matcher
+}
+
+function getRouteLoaderMatcher(loaders: Record) {
+ const existing = routeLoaderMatchers.get(loaders)
+ if (existing) return existing
+ const matcher = createRouteLoaderMatcher(loaders)
+ routeLoaderMatchers.set(loaders, matcher)
+ return matcher
+}
+
+export function matchRoute(
path: string,
routes: Record,
): JSX.Element | null {
- for (const [pattern, routeElement] of Object.entries(routes)) {
- const { pattern: compiled } = compileRoutePattern(pattern)
- const result = compiled.exec(path)
- if (!result) continue
- return routeElement
- }
+ return (
+ getRouteMatcher(routes).match(new URL(path, clientRouteOrigin))?.data ??
+ null
+ )
+}
- return null
+function matchRouteLoader(path: string) {
+ if (!activeRouteLoaders) return null
+ return (
+ getRouteLoaderMatcher(activeRouteLoaders).match(
+ new URL(path, clientRouteOrigin),
+ )?.data ?? null
+ )
}
function shouldHandleClick(event: MouseEvent, anchor: HTMLAnchorElement) {
@@ -244,12 +299,60 @@ function getPathWithSearchAndHashFromUrl(url: URL) {
return `${url.pathname}${url.search}${url.hash}`
}
-function navigateWithRefreshForSamePath(destination: URL) {
- if (
- getPathWithSearchAndHashFromUrl(destination) ===
- getCurrentPathWithSearchAndHash()
- ) {
- notify()
+function consumeProgrammaticNavigation(path: string) {
+ if (pendingProgrammaticNavigationPath !== path) return false
+ pendingProgrammaticNavigationPath = null
+ return true
+}
+
+async function preloadRouteData(destination: URL, signal: AbortSignal) {
+ const path = getPathWithSearchAndHashFromUrl(destination)
+ const loader = matchRouteLoader(path)
+ if (!loader) return null
+ return loader(destination, signal)
+}
+
+function commitRouteLoaderResult(
+ destination: URL,
+ result: RouteLoaderResult | null,
+) {
+ const path = getPathWithSearchAndHashFromUrl(destination)
+ if (!result) return false
+ if (isRouteLoaderRedirect(result)) {
+ window.location.assign(result.to)
+ return true
+ }
+ if (Object.keys(result).length > 0) {
+ setPreloadedNavigationData(path, result)
+ }
+ return false
+}
+
+async function preloadAndCommitNavigationData(
+ destination: URL,
+ signal: AbortSignal,
+) {
+ try {
+ return commitRouteLoaderResult(
+ destination,
+ await preloadRouteData(destination, signal),
+ )
+ } catch (error) {
+ if (signal.aborted) return false
+ markNavigationDataStale(getPathWithSearchAndHashFromUrl(destination))
+ console.error('Route loader failed', error)
+ return false
+ }
+}
+
+async function navigateWithRefreshForSamePath(destination: URL) {
+ const path = getPathWithSearchAndHashFromUrl(destination)
+ if (path === getCurrentPathWithSearchAndHash()) {
+ const redirected = await preloadAndCommitNavigationData(
+ destination,
+ new AbortController().signal,
+ )
+ if (!redirected) notify()
return
}
navigate(destination.toString())
@@ -298,7 +401,7 @@ async function submitFormThroughRouter(details: FormSubmitDetails) {
}
const destination = await submitPostFormThroughRouter(details)
- navigateWithRefreshForSamePath(destination)
+ await navigateWithRefreshForSamePath(destination)
}
function handleDocumentSubmit(event: Event) {
@@ -339,10 +442,22 @@ function handleNavigationEvent(event: RouterNavigateEvent) {
// handling is consistently supported across Navigation API browsers.
return
}
+ const destination = new URL(event.destination.url, window.location.href)
+ const nextPath = getPathWithSearchAndHashFromUrl(destination)
+ const isProgrammaticNavigation = consumeProgrammaticNavigation(nextPath)
event.intercept({
- handler() {
- notify()
+ async handler() {
+ if (isProgrammaticNavigation) {
+ notify()
+ return
+ }
+ const controller = new AbortController()
+ const redirected = await preloadAndCommitNavigationData(
+ destination,
+ controller.signal,
+ )
+ if (!redirected) notify()
},
})
}
@@ -364,16 +479,24 @@ function ensureRouter() {
}
export function listenToRouterNavigation(
- handle: Handle,
+ handle: Pick,
listener: () => void,
) {
+ if (typeof document === 'undefined') return
ensureRouter()
addEventListeners(routerEvents, handle.signal, {
navigate: () => listener(),
})
}
-export function getPathname() {
+export function getPathname(handle?: Pick) {
+ if (handle) {
+ try {
+ return readRouterPathname(handle)
+ } catch {
+ // Router location context is unavailable outside the app tree.
+ }
+ }
if (typeof window === 'undefined') return '/'
return window.location.pathname
}
@@ -396,6 +519,7 @@ export function navigate(to: string) {
const navigationApi = getNavigationApi()
if (navigationApi) {
+ pendingProgrammaticNavigationPath = nextPath
navigationApi.navigate(nextPath)
return
}
@@ -404,13 +528,36 @@ export function navigate(to: string) {
notify()
}
-export function Router(handle: Handle) {
- listenToRouterNavigation(handle, () => {
- void handle.update()
- })
+type RouterHandle = Pick & {
+ props: RouterSetup
+}
+
+function normalizeHref(href: string) {
+ const url = new URL(href, clientRouteOrigin)
+ return `${url.pathname}${url.search}${url.hash}`
+}
+
+function isOnSsrUrl(handle: Pick) {
+ return (
+ normalizeHref(readRouterUrl(handle)) ===
+ normalizeHref(readSsrRouterUrl(handle))
+ )
+}
+
+export function Router(handle: RouterHandle) {
+ activeRouteLoaders = handle.props.loaders ?? null
+ if (typeof document !== 'undefined') {
+ listenToRouterNavigation(handle, () => {
+ void handle.update()
+ })
+ }
return () => {
- const path = getPathname()
+ if (handle.props.notFound && isOnSsrUrl(handle)) {
+ return handle.props.fallback ?? null
+ }
+
+ const path = getPathname(handle)
const routeElement = matchRoute(path, handle.props.routes)
if (routeElement) return routeElement
return handle.props.fallback ?? null
diff --git a/client/entry.tsx b/client/entry.tsx
index 3cd4ede..5e18215 100644
--- a/client/entry.tsx
+++ b/client/entry.tsx
@@ -1,9 +1,28 @@
-import { createRoot } from 'remix/ui'
-import { App } from './app.tsx'
+import { run } from 'remix/ui'
+import { APP_ROOT_ENTRY_ID, AppRoot } from './app-root.tsx'
-const rootElement = document.getElementById('root') ?? document.body
-if (rootElement.childNodes.length > 0) {
- // Remix alpha.3 auto-hydrates non-empty containers. We render from scratch.
- rootElement.replaceChildren()
+const clientRegistry: Record = {
+ AppRoot,
}
-createRoot(rootElement).render()
+
+const app = run({
+ loadModule(moduleUrl, exportName) {
+ const expectedHref = APP_ROOT_ENTRY_ID.split('#')[0]
+ if (moduleUrl !== expectedHref) {
+ throw new Error(`Unknown client module URL: ${moduleUrl}`)
+ }
+
+ const component = clientRegistry[exportName]
+ if (!component) {
+ throw new Error(`Unknown client export: ${exportName}`)
+ }
+
+ return component
+ },
+})
+
+app.addEventListener('error', (event) => {
+ console.error('Client hydration error:', event.error)
+})
+
+void app.ready()
diff --git a/client/loader-data-context.tsx b/client/loader-data-context.tsx
new file mode 100644
index 0000000..5dbbd5a
--- /dev/null
+++ b/client/loader-data-context.tsx
@@ -0,0 +1,85 @@
+import { type Handle, type RemixNode } from 'remix/ui'
+import { type AppLoaderData } from '#shared/loader-data.ts'
+import { tryConsumePreloadedLoaderData } from './navigation-data.ts'
+import { readSsrRouterUrl } from './router-location.tsx'
+
+export type AppLoaderDataContextValue = {
+ loaderData?: AppLoaderData
+ consumedKeys: Set
+}
+
+const routerHrefOrigin = 'https://epicflare.local'
+
+function normalizeRouterHref(href: string) {
+ const url = new URL(href, routerHrefOrigin)
+ return `${url.pathname}${url.search}${url.hash}`
+}
+
+function hrefMatchesSsrUrl(currentHref: string, ssrUrl: string) {
+ return normalizeRouterHref(currentHref) === normalizeRouterHref(ssrUrl)
+}
+
+export function AppLoaderDataProvider(
+ handle: Handle<
+ { children?: RemixNode; loaderData?: AppLoaderData },
+ AppLoaderDataContextValue
+ >,
+) {
+ const consumedKeys = new Set()
+ handle.context.set({
+ loaderData: handle.props.loaderData,
+ consumedKeys,
+ })
+
+ return () => handle.props.children
+}
+
+export function tryConsumeEmbeddedLoaderData(
+ handle: Handle,
+ key: K,
+ currentHref: string,
+): AppLoaderData[K] | undefined {
+ const ctx = handle.context.get(AppLoaderDataProvider)
+ const embedded = ctx.loaderData?.[key]
+ if (!embedded) return undefined
+
+ let ssrUrl: string
+ try {
+ ssrUrl = readSsrRouterUrl(handle)
+ } catch {
+ return undefined
+ }
+
+ if (!hrefMatchesSsrUrl(currentHref, ssrUrl)) return undefined
+ if (ctx.consumedKeys.has(key)) return undefined
+
+ ctx.consumedKeys.add(key)
+ return embedded
+}
+
+/**
+ * Route-loader consumption happens mid-render and callers apply the payload by
+ * mutating route closure state. A follow-up render re-derives any values that
+ * were computed before consumption, while consume-once semantics prevent loops.
+ */
+function scheduleCorrectiveRender(handle: Handle) {
+ handle.queueTask(() => {
+ void handle.update()
+ })
+}
+
+export function tryConsumeRouteLoaderData(
+ handle: Handle,
+ key: K,
+ currentHref: string,
+): AppLoaderData[K] | undefined {
+ const embedded = tryConsumeEmbeddedLoaderData(handle, key, currentHref)
+ const data =
+ embedded !== undefined
+ ? embedded
+ : tryConsumePreloadedLoaderData(key, currentHref)
+ if (data !== undefined) {
+ scheduleCorrectiveRender(handle)
+ }
+ return data
+}
diff --git a/client/navigation-data.ts b/client/navigation-data.ts
new file mode 100644
index 0000000..49a6554
--- /dev/null
+++ b/client/navigation-data.ts
@@ -0,0 +1,54 @@
+import { type AppLoaderData } from '#shared/loader-data.ts'
+
+const routerHrefOrigin = 'https://epicflare.local'
+
+function normalizeNavigationHref(href: string) {
+ const url = new URL(href, routerHrefOrigin)
+ return `${url.pathname}${url.search}${url.hash}`
+}
+
+let preloadedSlot: { href: string; data: Partial } | null = null
+let staleNavigationHref: string | null = null
+
+export function setPreloadedNavigationData(
+ href: string,
+ data: Partial,
+) {
+ preloadedSlot = {
+ href: normalizeNavigationHref(href),
+ data: { ...data },
+ }
+}
+
+export function tryConsumePreloadedLoaderData(
+ key: K,
+ href: string,
+): AppLoaderData[K] | undefined {
+ if (!preloadedSlot) return undefined
+ if (normalizeNavigationHref(href) !== preloadedSlot.href) return undefined
+
+ const value = preloadedSlot.data[key]
+ if (value === undefined) return undefined
+
+ delete preloadedSlot.data[key]
+ if (Object.keys(preloadedSlot.data).length === 0) {
+ preloadedSlot = null
+ }
+ return value
+}
+
+export function markNavigationDataStale(href: string) {
+ staleNavigationHref = normalizeNavigationHref(href)
+}
+
+export function consumeStaleNavigationData(href: string) {
+ if (staleNavigationHref === null) return false
+ const isStale = staleNavigationHref === normalizeNavigationHref(href)
+ staleNavigationHref = null
+ return isStale
+}
+
+export function clearPreloadedNavigationData() {
+ preloadedSlot = null
+ staleNavigationHref = null
+}
diff --git a/client/route-loader.ts b/client/route-loader.ts
new file mode 100644
index 0000000..6b92f72
--- /dev/null
+++ b/client/route-loader.ts
@@ -0,0 +1,26 @@
+import { type AppLoaderData } from '#shared/loader-data.ts'
+
+export class RouteLoaderRedirect {
+ readonly to: string
+
+ constructor(to: string) {
+ this.to = to
+ }
+}
+
+export function routeLoaderRedirect(to: string) {
+ return new RouteLoaderRedirect(to)
+}
+
+export function isRouteLoaderRedirect(
+ value: unknown,
+): value is RouteLoaderRedirect {
+ return value instanceof RouteLoaderRedirect
+}
+
+export type RouteLoaderResult = Partial | RouteLoaderRedirect
+
+export type RouteLoader = (
+ url: URL,
+ signal: AbortSignal,
+) => Promise
diff --git a/client/router-location.tsx b/client/router-location.tsx
new file mode 100644
index 0000000..756c9da
--- /dev/null
+++ b/client/router-location.tsx
@@ -0,0 +1,57 @@
+import { addEventListeners, type Handle, type RemixNode } from 'remix/ui'
+import { routerEvents } from './client-router.tsx'
+
+export type RouterLocationValue = {
+ url: string
+ ssrUrl: string
+}
+
+function getClientUrl() {
+ if (typeof window === 'undefined') return '/'
+ return `${window.location.pathname}${window.location.search}${window.location.hash}`
+}
+
+export function RouterLocationProvider(
+ handle: Handle<{ url: string; children?: RemixNode }, RouterLocationValue>,
+) {
+ const ssrUrl = handle.props.url
+ let currentUrl = ssrUrl
+
+ if (typeof window !== 'undefined') {
+ handle.queueTask(() => {
+ currentUrl = getClientUrl()
+ handle.context.set({ url: currentUrl, ssrUrl })
+ handle.update()
+ })
+
+ addEventListeners(routerEvents, handle.signal, {
+ navigate() {
+ const nextUrl = getClientUrl()
+ if (nextUrl === currentUrl) return
+ currentUrl = nextUrl
+ handle.context.set({ url: currentUrl, ssrUrl })
+ handle.update()
+ },
+ })
+ }
+
+ handle.context.set({ url: currentUrl, ssrUrl })
+
+ return () => handle.props.children
+}
+
+export function readRouterUrl(handle: Pick) {
+ return handle.context.get(RouterLocationProvider).url
+}
+
+export function readSsrRouterUrl(handle: Pick) {
+ return handle.context.get(RouterLocationProvider).ssrUrl
+}
+
+export function readRouterPathname(handle: Pick) {
+ return new URL(readRouterUrl(handle), 'https://epicflare.local').pathname
+}
+
+export function readRouterSearch(handle: Pick) {
+ return new URL(readRouterUrl(handle), 'https://epicflare.local').search
+}
diff --git a/client/routes/account.tsx b/client/routes/account.tsx
index e49ae70..64d0bf1 100644
--- a/client/routes/account.tsx
+++ b/client/routes/account.tsx
@@ -1,10 +1,81 @@
import { css, type Handle } from 'remix/ui'
+import { buildAuthLink } from '#client/auth-links.ts'
+import { tryConsumeRouteLoaderData } from '#client/loader-data-context.tsx'
+import { consumeStaleNavigationData } from '#client/navigation-data.ts'
+import { readRouterUrl } from '#client/router-location.tsx'
+import { routeLoaderRedirect } from '#client/route-loader.ts'
+import { readSession } from '#client/session-context.tsx'
+import { fetchSessionInfo } from '#client/session.ts'
import { colors, spacing, typography } from '#client/styles/tokens.ts'
+import { routes } from '#server/routes.ts'
+import { type AccountLoaderData } from '#shared/loader-data.ts'
type AccountStatus = 'idle' | 'loading' | 'ready' | 'error'
+type SessionPayload = {
+ ok?: boolean
+ session?: {
+ email?: unknown
+ }
+}
+function getCurrentHref(handle: Pick) {
+ try {
+ return readRouterUrl(handle)
+ } catch {
+ return typeof window === 'undefined'
+ ? routes.account.href()
+ : `${window.location.pathname}${window.location.search}${window.location.hash}`
+ }
+}
+function setLoginRedirect(url: URL) {
+ return routeLoaderRedirect(
+ buildAuthLink(routes.login.href(), `${url.pathname}${url.search}`),
+ )
+}
+export async function loadAccountRouteData(url: URL, signal: AbortSignal) {
+ const session = await fetchSessionInfo(signal)
+ if (!session) return setLoginRedirect(url)
+ const account: AccountLoaderData = {
+ ok: true,
+ email: session.email,
+ }
+ return {
+ account,
+ }
+}
export function AccountRoute(handle: Handle) {
- let status: AccountStatus = 'loading'
- let email = ''
+ function readEmbeddedEmail() {
+ return readSession(handle)?.email.trim() ?? ''
+ }
+ const initialEmail = readEmbeddedEmail()
+ let status: AccountStatus = initialEmail ? 'ready' : 'loading'
+ let email = initialEmail
let message: string | null = null
+ let loadInFlight = false
+ let lastLoaderHref: string | null = null
+ function syncRouteLoaderData() {
+ const href = getCurrentHref(handle)
+ const isStale = consumeStaleNavigationData(href)
+ if (!isStale && href === lastLoaderHref) return
+ lastLoaderHref = href
+ const data = tryConsumeRouteLoaderData(handle, 'account', href)
+ if (!data?.ok) return
+ email = data.email
+ status = 'ready'
+ message = null
+ }
+ function syncEmbeddedSession() {
+ const nextEmail = readEmbeddedEmail()
+ if (nextEmail) {
+ email = nextEmail
+ status = 'ready'
+ message = null
+ return
+ }
+ if (status === 'ready') {
+ email = ''
+ status = 'loading'
+ message = null
+ }
+ }
async function loadAccount(signal: AbortSignal) {
try {
const response = await fetch('/session', {
@@ -13,7 +84,9 @@ export function AccountRoute(handle: Handle) {
signal,
})
if (signal.aborted) return
- const payload = await response.json().catch(() => null)
+ const payload = (await response
+ .json()
+ .catch(() => null)) as SessionPayload | null
const sessionEmail =
response.ok &&
payload?.ok &&
@@ -21,7 +94,7 @@ export function AccountRoute(handle: Handle) {
? payload.session.email.trim()
: ''
if (!sessionEmail) {
- window.location.assign('/login')
+ window.location.assign(routes.login.href())
return
}
email = sessionEmail
@@ -33,10 +106,19 @@ export function AccountRoute(handle: Handle) {
status = 'error'
message = 'Unable to load your account.'
handle.update()
+ } finally {
+ loadInFlight = false
}
}
return () => {
- if (status === 'loading') {
+ syncRouteLoaderData()
+ syncEmbeddedSession()
+ if (
+ typeof window !== 'undefined' &&
+ status === 'loading' &&
+ !loadInFlight
+ ) {
+ loadInFlight = true
handle.queueTask(loadAccount)
}
return (
diff --git a/client/routes/chat.tsx b/client/routes/chat.tsx
index ea5ebcd..20574d5 100644
--- a/client/routes/chat.tsx
+++ b/client/routes/chat.tsx
@@ -1,6 +1,7 @@
import { addEventListeners, css, on, type Handle } from 'remix/ui'
+import { buildAuthLink } from '#client/auth-links.ts'
import { ChatClient, type ChatClientSnapshot } from '#client/chat-client.ts'
-import { navigate, routerEvents } from '#client/client-router.tsx'
+import { getPathname, navigate, routerEvents } from '#client/client-router.tsx'
import { EditableText } from '#client/editable-text.tsx'
import {
createInfiniteList,
@@ -13,7 +14,12 @@ import {
restoreScrollAnchorAfterPrepend,
scrollToEdge,
} from '#client/scroll-container.ts'
+import { tryConsumeRouteLoaderData } from '#client/loader-data-context.tsx'
+import { consumeStaleNavigationData } from '#client/navigation-data.ts'
+import { readRouterUrl } from '#client/router-location.tsx'
+import { routeLoaderRedirect } from '#client/route-loader.ts'
import { createSpinDelay } from '#client/spin-delay.ts'
+import { routes } from '#server/routes.ts'
import {
breakpoints,
colors,
@@ -30,16 +36,22 @@ import {
type ChatThreadSummary,
type ChatThreadUpdateResponse,
} from '#shared/chat.ts'
+import { type ChatLoaderData } from '#shared/loader-data.ts'
type ThreadStatus = 'idle' | 'loading' | 'ready' | 'error'
-function getSelectedThreadIdFromLocation() {
- if (typeof window === 'undefined') return null
- const prefix = '/chat/'
- if (!window.location.pathname.startsWith(prefix)) return null
- const threadId = window.location.pathname.slice(prefix.length).trim()
- return threadId || null
+function getSelectedThreadIdFromLocation(handle: Pick) {
+ return getSelectedThreadIdFromPathname(getPathname(handle))
}
function buildThreadHref(threadId: string) {
- return `/chat/${threadId}`
+ return routes.chatThread.href({ threadId })
+}
+function getCurrentHref(handle: Pick) {
+ try {
+ return readRouterUrl(handle)
+ } catch {
+ return typeof window === 'undefined'
+ ? routes.chat.href()
+ : `${window.location.pathname}${window.location.search}${window.location.hash}`
+ }
}
function isMobileViewport() {
return (
@@ -165,6 +177,75 @@ async function fetchThreadById(threadId: string, signal?: AbortSignal) {
}
return payload.thread
}
+async function loadChatThreadsData(url: URL, signal?: AbortSignal) {
+ const threadsUrl = new URL(routes.chatThreads.href(), url.origin)
+ threadsUrl.searchParams.set('limit', String(THREADS_PAGE_LIMIT))
+ const response = await fetch(threadsUrl.toString(), {
+ credentials: 'include',
+ headers: { Accept: 'application/json' },
+ signal,
+ })
+ if (response.status === 401) {
+ return routeLoaderRedirect(
+ buildAuthLink(routes.login.href(), `${url.pathname}${url.search}`),
+ )
+ }
+ const payload = (await response.json().catch(() => null)) as
+ | (ChatThreadListResponse & { error?: string })
+ | null
+ if (
+ !response.ok ||
+ !payload?.ok ||
+ !Array.isArray(payload.threads) ||
+ typeof payload.totalCount !== 'number' ||
+ typeof payload.hasMore !== 'boolean'
+ ) {
+ throw new Error(payload?.error || 'Unable to load threads.')
+ }
+ const selectedThreadId = getSelectedThreadIdFromPathname(url.pathname)
+ let selectedThread = payload.threads.find(
+ (thread) => thread.id === selectedThreadId,
+ )
+ if (selectedThreadId && !selectedThread) {
+ const threadUrl = new URL(routes.chatThreads.href(), url.origin)
+ threadUrl.searchParams.set('threadId', selectedThreadId)
+ const threadResponse = await fetch(threadUrl.toString(), {
+ credentials: 'include',
+ headers: { Accept: 'application/json' },
+ signal,
+ })
+ const threadPayload = (await threadResponse.json().catch(() => null)) as
+ | (ChatThreadLookupResponse & { error?: string })
+ | null
+ if (threadResponse.ok && threadPayload?.ok && threadPayload.thread) {
+ selectedThread = threadPayload.thread
+ }
+ }
+ const threads =
+ selectedThread &&
+ !payload.threads.some((thread) => thread.id === selectedThread.id)
+ ? [selectedThread, ...payload.threads]
+ : payload.threads
+ const data: ChatLoaderData = {
+ ok: true,
+ threads,
+ hasMore: payload.hasMore,
+ nextCursor: payload.nextCursor,
+ totalCount: payload.totalCount,
+ selectedThread: selectedThread ?? null,
+ search: '',
+ }
+ return { chat: data }
+}
+function getSelectedThreadIdFromPathname(pathname: string) {
+ const prefix = `${routes.chat.href()}/`
+ if (!pathname.startsWith(prefix)) return null
+ const threadId = pathname.slice(prefix.length).trim()
+ return threadId || null
+}
+export async function loadChatRouteData(url: URL, signal: AbortSignal) {
+ return loadChatThreadsData(url, signal)
+}
async function createThread() {
const response = await fetch('/chat-threads', {
method: 'POST',
@@ -373,10 +454,12 @@ export function ChatRoute(handle: Handle) {
let threadListCursor: string | null = null
let activeThreadId: string | null = null
let threadSearch = ''
+ let lastLoaderHref: string | null = null
let chatSnapshot = createInitialSnapshot()
let activeClient: ChatClient | null = null
let actionError: string | null = null
let syncInFlight = false
+ let suppressThreadListSnapshotUpdate = false
let shouldAutoScrollMessages = true
let showMessageScrollFadeTop = false
let showMessageScrollFadeBottom = false
@@ -407,7 +490,9 @@ export function ChatRoute(handle: Handle) {
} else {
threadStatus = 'ready'
}
- update()
+ if (!suppressThreadListSnapshotUpdate) {
+ update()
+ }
},
})
function update() {
@@ -421,6 +506,45 @@ export function ChatRoute(handle: Handle) {
threadError = nextError
update()
}
+ function applyChatLoaderData(data: ChatLoaderData) {
+ threadSearch = data.search
+ threadListCursor = data.nextCursor
+ suppressThreadListSnapshotUpdate = true
+ try {
+ threadList.replaceWindow({
+ items: data.threads,
+ hasMore: data.hasMore,
+ totalCount: data.totalCount,
+ })
+ } finally {
+ suppressThreadListSnapshotUpdate = false
+ }
+ threadStatus = 'ready'
+ threadError = null
+ const locationThreadId = getSelectedThreadIdFromLocation(handle)
+ const selectedThreadId =
+ locationThreadId &&
+ data.threads.some((thread) => thread.id === locationThreadId)
+ ? locationThreadId
+ : (data.threads[0]?.id ?? null)
+ if (activeThreadId !== selectedThreadId) {
+ activeThreadId = selectedThreadId
+ resetChatSnapshot()
+ }
+ scheduleThreadListScrollFadeSync()
+ if (typeof window !== 'undefined') {
+ void handle.queueTask(async () => {
+ await syncActiveThreadFromLocation()
+ })
+ }
+ }
+ function applyRouteLoaderData(href: string) {
+ const data = tryConsumeRouteLoaderData(handle, 'chat', href)
+ if (!data?.ok) return false
+ applyChatLoaderData(data)
+ lastLoaderHref = href
+ return true
+ }
function resetChatSnapshot() {
chatSnapshot = createInitialSnapshot()
}
@@ -644,7 +768,7 @@ export function ChatRoute(handle: Handle) {
if (threadStatus !== 'ready' || syncInFlight) return
syncInFlight = true
try {
- const locationThreadId = getSelectedThreadIdFromLocation()
+ const locationThreadId = getSelectedThreadIdFromLocation(handle)
const threads = getThreads()
if (threads.length === 0) {
activeClient?.close()
@@ -655,7 +779,7 @@ export function ChatRoute(handle: Handle) {
setMessageScrollFades(false, false)
update()
if (locationThreadId) {
- navigate('/chat')
+ navigate(routes.chat.href())
}
return
}
@@ -746,13 +870,15 @@ export function ChatRoute(handle: Handle) {
)
}
}
- addEventListeners(routerEvents, handle.signal, {
- navigate: () => {
- void handle.queueTask(async () => {
- await syncActiveThreadFromLocation()
- })
- },
- })
+ if (typeof window !== 'undefined') {
+ addEventListeners(routerEvents, handle.signal, {
+ navigate: () => {
+ void handle.queueTask(async () => {
+ await syncActiveThreadFromLocation()
+ })
+ },
+ })
+ }
async function createAndSelectThread() {
const thread = await createThread()
navigate(buildThreadHref(thread.id))
@@ -792,7 +918,7 @@ export function ChatRoute(handle: Handle) {
navigate(buildThreadHref(nextThread.id))
await connectThread(nextThread.id)
} else {
- navigate('/chat')
+ navigate(routes.chat.href())
}
} catch (error) {
actionError =
@@ -865,7 +991,18 @@ export function ChatRoute(handle: Handle) {
}
}
return () => {
- if (threadStatus === 'loading') {
+ const currentHref = getCurrentHref(handle)
+ const appliedRouteData = applyRouteLoaderData(currentHref)
+ const needsStaleRefresh =
+ consumeStaleNavigationData(currentHref) && !appliedRouteData
+ if (
+ !appliedRouteData &&
+ (threadStatus === 'loading' ||
+ needsStaleRefresh ||
+ currentHref !== lastLoaderHref) &&
+ typeof window !== 'undefined'
+ ) {
+ lastLoaderHref = currentHref
handle.queueTask(refreshThreads)
}
const threads = getThreads()
@@ -874,7 +1011,7 @@ export function ChatRoute(handle: Handle) {
: null
const showEmptyStateComposer =
!activeThread && threads.length === 0 && threadStatus !== 'error'
- const hasThreadInUrl = Boolean(getSelectedThreadIdFromLocation())
+ const hasThreadInUrl = Boolean(getSelectedThreadIdFromLocation(handle))
return (
Remix 3
- Remix 3 components running on the client, backed by Remix 3
- routing in the worker.
+ Remix 3 components server-rendered by the worker, then hydrated
+ for client navigation.
diff --git a/client/routes/index.tsx b/client/routes/index.tsx
index f0c7aa8..b59488f 100644
--- a/client/routes/index.tsx
+++ b/client/routes/index.tsx
@@ -1,19 +1,30 @@
-import { AccountRoute } from './account.tsx'
-import { ChatRoute } from './chat.tsx'
+import { routes } from '#server/routes.ts'
+import { AccountRoute, loadAccountRouteData } from './account.tsx'
+import { ChatRoute, loadChatRouteData } from './chat.tsx'
import { HomeRoute } from './home.tsx'
import { LoginRoute } from './login.tsx'
-import { OAuthAuthorizeRoute } from './oauth-authorize.tsx'
+import {
+ loadOAuthAuthorizeRouteData,
+ OAuthAuthorizeRoute,
+} from './oauth-authorize.tsx'
import { OAuthCallbackRoute } from './oauth-callback.tsx'
import { ResetPasswordRoute } from './reset-password.tsx'
export const clientRoutes = {
- '/': ,
- '/chat': ,
+ [routes.home.href()]: ,
+ [routes.chat.href()]: ,
'/chat/:threadId': ,
- '/account': ,
- '/login': ,
- '/signup': ,
- '/reset-password': ,
- '/oauth/authorize': ,
- '/oauth/callback': ,
+ [routes.account.href()]: ,
+ [routes.login.href()]: ,
+ [routes.signup.href()]: ,
+ [routes.resetPassword.href()]: ,
+ [routes.oauthAuthorize.href()]: ,
+ [routes.oauthCallback.href()]: ,
+}
+
+export const clientRouteLoaders = {
+ [routes.chat.href()]: loadChatRouteData,
+ '/chat/:threadId': loadChatRouteData,
+ [routes.account.href()]: loadAccountRouteData,
+ [routes.oauthAuthorize.href()]: loadOAuthAuthorizeRouteData,
}
diff --git a/client/routes/login.tsx b/client/routes/login.tsx
index ed312b9..5fd7395 100644
--- a/client/routes/login.tsx
+++ b/client/routes/login.tsx
@@ -4,6 +4,8 @@ import {
getPathname,
listenToRouterNavigation,
} from '#client/client-router.tsx'
+import { readRouterSearch } from '#client/router-location.tsx'
+import { routes } from '#server/routes.ts'
import { fetchSessionInfo, type SessionStatus } from '#client/session.ts'
import {
colors,
@@ -15,10 +17,19 @@ import {
} from '#client/styles/tokens.ts'
type AuthMode = 'login' | 'signup'
type AuthStatus = 'idle' | 'submitting' | 'success' | 'error'
-function getSearchParams() {
- return typeof window === 'undefined'
- ? new URLSearchParams()
- : new URLSearchParams(window.location.search)
+type AuthPayload = {
+ error?: unknown
+}
+function getSearchParams(handle: Handle) {
+ if (typeof window !== 'undefined') {
+ return new URLSearchParams(window.location.search)
+ }
+
+ try {
+ return new URLSearchParams(readRouterSearch(handle))
+ } catch {
+ return new URLSearchParams()
+ }
}
function normalizeRedirectTo(value: string | null) {
if (!value) return null
@@ -27,24 +38,24 @@ function normalizeRedirectTo(value: string | null) {
return value
}
function buildAuthPath(mode: AuthMode, redirectTo: string | null) {
- const path = mode === 'signup' ? '/signup' : '/login'
+ const path = mode === 'signup' ? routes.signup.href() : routes.login.href()
return buildAuthLink(path, redirectTo)
}
function getAuthModeFromPathname(pathname: string): AuthMode {
- return pathname === '/signup' ? 'signup' : 'login'
+ return pathname === routes.signup.href() ? 'signup' : 'login'
}
-function getCurrentAuthMode() {
- return getAuthModeFromPathname(getPathname())
+function getCurrentAuthMode(handle: Handle) {
+ return getAuthModeFromPathname(getPathname(handle))
}
-function getCurrentRedirectTo() {
- return normalizeRedirectTo(getSearchParams().get('redirectTo'))
+function getCurrentRedirectTo(handle: Handle) {
+ return normalizeRedirectTo(getSearchParams(handle).get('redirectTo'))
}
export function LoginRoute(handle: Handle) {
let status: AuthStatus = 'idle'
let message: string | null = null
let sessionStatus: SessionStatus = 'idle'
let sessionEmail = ''
- let activeMode = getCurrentAuthMode()
+ let activeMode = getCurrentAuthMode(handle)
let routePath: string | null = null
function setState(nextStatus: AuthStatus, nextMessage: string | null = null) {
status = nextStatus
@@ -57,30 +68,34 @@ export function LoginRoute(handle: Handle) {
}
listenToRouterNavigation(handle, () => {
if (!routePath) return
- if (getPathname() !== routePath) {
+ if (getPathname(handle) !== routePath) {
resetAuthState()
}
})
- handle.queueTask(async (signal) => {
- if (sessionStatus !== 'idle') return
- sessionStatus = 'loading'
- const session = await fetchSessionInfo(signal)
- if (signal.aborted) return
- sessionEmail = session?.email ?? ''
- sessionStatus = 'ready'
- if (sessionEmail && typeof window !== 'undefined') {
- window.location.assign(getCurrentRedirectTo() ?? '/account')
- return
- }
- handle.update()
- })
+ if (typeof window !== 'undefined') {
+ handle.queueTask(async (signal) => {
+ if (sessionStatus !== 'idle') return
+ sessionStatus = 'loading'
+ const session = await fetchSessionInfo(signal)
+ if (signal.aborted) return
+ sessionEmail = session?.email ?? ''
+ sessionStatus = 'ready'
+ if (sessionEmail) {
+ window.location.assign(
+ getCurrentRedirectTo(handle) ?? routes.account.href(),
+ )
+ return
+ }
+ handle.update()
+ })
+ }
async function handleSubmit(event: SubmitEvent) {
event.preventDefault()
if (!(event.currentTarget instanceof HTMLFormElement)) return
const formData = new FormData(event.currentTarget)
const email = String(formData.get('email') ?? '').trim()
const password = String(formData.get('password') ?? '')
- const mode = getCurrentAuthMode()
+ const mode = getCurrentAuthMode(handle)
const rememberMe = mode === 'login' && formData.get('rememberMe') === 'on'
if (!email || !password) {
setState('error', 'Email and password are required.')
@@ -94,7 +109,9 @@ export function LoginRoute(handle: Handle) {
credentials: 'include',
body: JSON.stringify({ email, password, mode, rememberMe }),
})
- const payload = await response.json().catch(() => null)
+ const payload = (await response
+ .json()
+ .catch(() => null)) as AuthPayload | null
if (!response.ok) {
const errorMessage =
typeof payload?.error === 'string'
@@ -104,22 +121,24 @@ export function LoginRoute(handle: Handle) {
return
}
if (typeof window !== 'undefined') {
- window.location.assign(getCurrentRedirectTo() ?? '/account')
+ window.location.assign(
+ getCurrentRedirectTo(handle) ?? routes.account.href(),
+ )
}
} catch {
setState('error', 'Network error. Please try again.')
}
}
return () => {
- const mode = getCurrentAuthMode()
+ const mode = getCurrentAuthMode(handle)
if (!routePath) {
- routePath = getPathname()
+ routePath = getPathname(handle)
}
if (mode !== activeMode) {
activeMode = mode
resetAuthState()
}
- const redirectTo = getCurrentRedirectTo()
+ const redirectTo = getCurrentRedirectTo(handle)
const isSignup = mode === 'signup'
const isSubmitting = status === 'submitting'
const title = isSignup ? 'Create your account' : 'Welcome back'
@@ -344,7 +363,7 @@ export function LoginRoute(handle: Handle) {
{!isSignup ? (
) : null}
+}
+type OAuthAuthorizeDecisionPayload = {
+ error?: unknown
+ redirectTo?: unknown
+}
+function getSearch(handle: Handle) {
+ if (typeof window !== 'undefined') return window.location.search
+
+ try {
+ return readRouterSearch(handle)
+ } catch {
+ return ''
+ }
+}
+
+function getSearchParams(handle: Handle) {
+ return new URLSearchParams(getSearch(handle))
+}
+function readQueryErrorFromParams(params: URLSearchParams) {
+ const description = params.get('error_description')
+ if (description) return description
+ const error = params.get('error')
+ return error ? `Authorization error: ${error}` : null
+}
+function getCurrentHref(handle: Pick) {
+ try {
+ return readRouterUrl(handle)
+ } catch {
+ return typeof window === 'undefined'
+ ? routes.oauthAuthorize.href()
+ : `${window.location.pathname}${window.location.search}${window.location.hash}`
+ }
+}
+async function fetchOAuthAuthorizeData(
+ url: URL,
+ signal?: AbortSignal,
+): Promise {
+ const queryError = readQueryErrorFromParams(url.searchParams)
+ if (queryError) return { ok: false, error: queryError }
+ const response = await fetch(`${routes.oauthAuthorize.href()}-info${url.search}`, {
+ headers: { Accept: 'application/json' },
+ credentials: 'include',
+ signal,
+ })
+ const payload = (await response
+ .json()
+ .catch(() => null)) as OAuthAuthorizeInfoPayload | null
+ if (
+ !response.ok ||
+ !payload?.ok ||
+ !payload.client ||
+ !Array.isArray(payload.scopes)
+ ) {
+ return {
+ ok: false,
+ error:
+ typeof payload?.error === 'string'
+ ? payload.error
+ : 'Unable to load authorization details.',
+ }
+ }
+ return {
+ ok: true,
+ client: payload.client,
+ scopes: payload.scopes,
+ }
+}
+export async function loadOAuthAuthorizeRouteData(
+ url: URL,
+ signal: AbortSignal,
+) {
+ return {
+ oauthAuthorize: await fetchOAuthAuthorizeData(url, signal),
+ }
}
export function OAuthAuthorizeRoute(handle: Handle) {
let info: OAuthAuthorizeInfo | null = null
let status: OAuthAuthorizeStatus = 'idle'
- let message: OAuthAuthorizeMessage | null = null
+ const initialQueryError = readQueryError()
+ let message: OAuthAuthorizeMessage | null = initialQueryError
+ ? { type: 'error', text: initialQueryError }
+ : null
let submitting = false
let lastSearch = ''
- let session: SessionInfo | null = null
- let sessionStatus: SessionStatus = 'idle'
+ let lastLoaderHref: string | null = null
+ let session: SessionInfo | null = readSession(handle)
+ let infoLoadQueued = false
function setMessage(next: OAuthAuthorizeMessage | null) {
message = next
handle.update()
}
function readQueryError() {
- const params = getSearchParams()
- const description = params.get('error_description')
- if (description) return description
- const error = params.get('error')
- return error ? `Authorization error: ${error}` : null
+ return readQueryErrorFromParams(getSearchParams(handle))
}
- async function loadInfo() {
- status = 'loading'
- const queryError = readQueryError()
- if (queryError) {
- message = { type: 'error', text: queryError }
- }
- try {
- const query = typeof window === 'undefined' ? '' : window.location.search
- const response = await fetch(`/oauth/authorize-info${query}`, {
- headers: { Accept: 'application/json' },
- credentials: 'include',
- })
- const payload = await response.json().catch(() => null)
- if (!response.ok || !payload?.ok) {
- const errorText =
- typeof payload?.error === 'string'
- ? payload.error
- : 'Unable to load authorization details.'
- info = null
- status = 'error'
- message = { type: 'error', text: errorText }
- handle.update()
- return
- }
+ function syncEmbeddedSession() {
+ session = readSession(handle)
+ }
+ function applyLoaderData(data: OAuthAuthorizeLoaderData) {
+ if (data.ok) {
info = {
- client: payload.client,
- scopes: payload.scopes,
+ client: data.client,
+ scopes: data.scopes,
}
status = 'ready'
- if (!queryError) {
+ if (!readQueryError()) {
message = null
}
+ return
+ }
+ info = null
+ status = 'error'
+ message = { type: 'error', text: data.error }
+ }
+ function syncRouteLoaderData() {
+ const href = getCurrentHref(handle)
+ const data = tryConsumeRouteLoaderData(handle, 'oauthAuthorize', href)
+ if (!data) {
+ if (href !== lastLoaderHref) {
+ lastLoaderHref = href
+ }
+ return false
+ }
+ applyLoaderData(data)
+ lastSearch = getSearch(handle)
+ lastLoaderHref = href
+ return true
+ }
+ async function loadInfo() {
+ status = 'loading'
+ const url = new URL(getSearch(handle) || routes.oauthAuthorize.href(), window.location.href)
+ try {
+ applyLoaderData(await fetchOAuthAuthorizeData(url))
handle.update()
} catch {
+ const queryError = readQueryError()
info = null
status = 'error'
message = {
type: 'error',
- text: 'Unable to load authorization details.',
+ text: queryError ?? 'Unable to load authorization details.',
}
handle.update()
}
}
- async function loadSession() {
- if (sessionStatus !== 'idle') return
- sessionStatus = 'loading'
- session = await fetchSessionInfo()
- sessionStatus = 'ready'
- handle.update()
- }
async function submitDecision(
decision: 'approve' | 'deny',
form?: HTMLFormElement,
@@ -132,7 +209,9 @@ export function OAuthAuthorizeRoute(handle: Handle) {
credentials: 'include',
body,
})
- const payload = await response.json().catch(() => null)
+ const payload = (await response
+ .json()
+ .catch(() => null)) as OAuthAuthorizeDecisionPayload | null
if (!response.ok) {
const errorText =
typeof payload?.error === 'string'
@@ -143,7 +222,7 @@ export function OAuthAuthorizeRoute(handle: Handle) {
handle.update()
return
}
- if (payload?.redirectTo) {
+ if (typeof payload?.redirectTo === 'string') {
window.location.assign(payload.redirectTo)
return
}
@@ -168,26 +247,35 @@ export function OAuthAuthorizeRoute(handle: Handle) {
)
}
return () => {
- const currentSearch =
- typeof window === 'undefined' ? '' : window.location.search
- if (currentSearch !== lastSearch) {
+ const href = getCurrentHref(handle)
+ const appliedRouteData = syncRouteLoaderData()
+ const needsStaleRefresh =
+ consumeStaleNavigationData(href) && !appliedRouteData
+ syncEmbeddedSession()
+ const currentSearch = getSearch(handle)
+ if (
+ typeof window !== 'undefined' &&
+ (needsStaleRefresh || currentSearch !== lastSearch) &&
+ !infoLoadQueued
+ ) {
lastSearch = currentSearch
- void loadInfo()
- }
- if (sessionStatus === 'idle') {
- void loadSession()
+ infoLoadQueued = true
+ handle.queueTask(async () => {
+ try {
+ await loadInfo()
+ } finally {
+ infoLoadQueued = false
+ }
+ })
}
const clientLabel = info?.client?.name ?? 'Unknown client'
const scopes = info?.scopes ?? []
const scopeLabel =
scopes.length > 0 ? scopes.join(', ') : 'No scopes requested.'
const sessionEmail = session?.email ?? ''
- const isSessionReady = sessionStatus === 'ready'
- const isSessionLoading =
- sessionStatus === 'loading' || sessionStatus === 'idle'
- const isLoggedIn = isSessionReady && Boolean(sessionEmail)
- const actionsDisabled = status !== 'ready' || submitting || isSessionLoading
- const formReady = status === 'ready' && !isSessionLoading
+ const isLoggedIn = Boolean(sessionEmail)
+ const actionsDisabled = status !== 'ready' || submitting
+ const formReady = status === 'ready'
const authorizeLabel = submitting
? 'Submitting...'
: isLoggedIn
@@ -248,9 +336,6 @@ export function OAuthAuthorizeRoute(handle: Handle) {
{scopeLabel}
- {isSessionLoading ? (
- Checking your session…
- ) : null}
{isLoggedIn ? (
('submit', handleSubmit),
]}
>
- {!isLoggedIn && isSessionReady ? (
+ {!isLoggedIn ? (
<>