Skip to main content

Reading the session

Node reads the session once per request, and everything else reads Node's copy.

  • The session middleware on each root route asks Rails who is signed in, at most once per request.
  • The root loader puts the answer in route data.
  • Policies and components read that route data. Nothing else calls GET /identities/user_sessions/current.
  • This page covers five things.
    • How the routes nest, and where the session lives inside one request.
    • Where the read happens, on a document request and on a click inside the app.
    • The three rules for calling GET /identities/user_sessions/current.
    • Where the session is kept, and who reads it.
    • When React Router reads it again.

The words this page uses​

WordMeaning
React RouterThe library that runs JodApp Web's routes, in both runtimes. In the browser it handles every click and form submit, runs the clientLoader and clientAction exports, and sends .data requests to Node. On Node it runs the middleware and loader exports for each request. When a page says React Router does something, it means this library code, not code we wrote
Root routeThe first route every request matches on its host. app/roots/public-root.jsx for jodapp.com, app/roots/team-root.jsx for teamjod.app. Each is a layout() route with no URL segment. It draws nothing and exports the session middleware and the root loader
MiddlewareA function a route lists in its middleware export. Node runs it before any loader on a document request or a .data request. It receives the request and the router context, and it calls next() to let the rest of the request run
Router contextA small store that React Router creates for one request and throws away when the request ends. It is made with createContext from react-router, which is not React's createContext
Session middlewareauthUserSession.middleware in app/auth/auth-user-session-middleware.js, and its admin twin authAdminSession.middleware. It puts one function, the getter, in router context
The gettergetIdentitiesUserSessionsCurrent(), named after what it fetches: identitiesUserSessionsApi.current(), which returns the current-session JSON. The first caller starts the request to Rails. Every later caller in the same request receives the same promise. The admin twin is getTeamIdentitiesAdminSessionsCurrent(), after teamIdentitiesAdminSessionsApi.current()
identitiesUserSessionThe current-session JSON for the signed-in Identities::User, as route data. null when nobody is signed in. The admin twin is identitiesAdminSession
Root loaderThe loader export on the root route. It calls the getter and returns { identitiesUserSession }, or { identitiesAdminSession }
Route dataWhat the loaders of the matched routes returned. Node writes it into the HTML, and the browser keeps it for the page. A component reads a route's data with useRouteLoaderData(routeId)
RevalidationReact Router loading route data again, so the page shows current records. In the browser it sends one .data request to Node for the matched loader exports, and runs the matched clientLoader exports. React Router revalidation in JodApp Web states the rules

The session inside one request​

Every route that /talent/profile matches is nested inside the root route. The root's middleware runs first, and the root's route data reaches every route inside it.

  • The session is kept in two places during a request, and in no other place.
    • Inside Node, in router context, as one shared promise. It lives for one request.
    • In route data, which Node writes into the response and the browser keeps for the page.
  • The picture shows the four routes as boxes, each inside the one above it, the way app/routes.js nests them.

Legend: Each dashed box is one route, nested inside its parent route the way app/routes.js nests them. Orange is JodApp Web code, this page's subject. Lavender is where the session is kept: router context inside Node for one request, and route data on its way to the browser. Grey is Rails. The step numbers are the order the exports run, listed in the table below.

The same four routes in app/routes.js, outermost first:

layout('roots/public-root.jsx', [                            // middleware: the session. loader: identitiesUserSession
layout('layouts/careers/careers-public-layout.jsx', [ // component only: navbar and footer
layout('routes/policies/talent-required-policy.jsx', [ // middleware: the guard. loader: () => null
...prefix('talent', [
route('profile', './routes/talent/profile/talent-profile-page.jsx') // clientLoader and the page
])
])
])
])

The order the exports run:

StepRuns inRouteExportWhat it does
1NodeRootmiddlewareStores the getter in router context. Calls nothing
2NodePolicymiddlewareCalls the getter, waits for Rails, then allows the request or throws a redirect
3NodeRootloaderCalls the getter, receives the same answer, returns { identitiesUserSession }
3NodePolicyloaderReturns null. It exists so that a click inside the app sends a .data request to Node
4BrowserPageclientLoaderFetches the page's own data straight from Rails, with the cookie
5BrowserPagecomponentReads the session with useIdentitiesUserSession()
  • Middleware runs from the outside in. The root's runs before the policy's.
  • Every loader runs after every middleware, and the loaders of one request run together.
  • The visual layout exports no middleware, no loader and no action. It only draws.
  • A layout() route has no URL segment, so none of the three outer routes changes the path.

Three things to hold on to from the picture.

  • The root middleware stores a function, not a session.
    • Nothing is fetched until the policy or the root loader asks.
  • The root loader is the only code that turns the answer into route data.
    • Every route inside the root can read that route data, and every component reads it through one hook.
  • When the session changes, the same loader runs again and replaces the route data.
    • Nothing edits the copy in the browser. The last section says when that happens.

Where the read happens​

The read runs inside Node, on every request that carries a session cookie. Mei opens /talent/profile in a new tab.

Legend: The orange box is JodApp Web, this page's subject. Every note is one step inside Node. Rails is called once, by whichever code asks first. On a page with no policy, the root loader asks first. On a page with a policy, the policy asks first and the root loader shares its answer.

The steps, in order:

  1. The browser sends the request with the cookies it holds for .jodapp.com.
  2. The root middleware runs first.
    • It checks whether the Cookie header carries jodapp_session_id.
    • It stores the getter in router context.
    • It does not call Rails.
  3. The next middleware in the chain runs. On a protected page that is a policy.
    • The policy calls the getter, which starts the request to Rails.
    • The policy waits for the answer and decides whether the page may open.
  4. The loaders run. The root loader calls the getter.
    • The request to Rails is already running or finished, so the loader receives the same promise.
    • The loader returns { identitiesUserSession } as route data.
  5. Node renders the page and answers the browser.
    • On the way out, the root middleware adds Cache-Control: private, no-store to the response.

A click inside the app reaches the same code. Two things differ.

DifferenceOn a click inside the app
What the browser asks Node forA .data request, which returns route data, not HTML
Whether the browser sends a .data request to NodeOnly when React Router decides that some matched route's loader must run. A policy route's loader runs when the click enters the policy's area, so a click into a protected area always reaches Node. A click between two pages already inside the area sends nothing to Node unless the destination page has its own server loader. The policy loader page has the full list

The browser's own clientLoader calls go straight to Rails. They never read the session and never pass through this middleware.

Three rules for calling GET /identities/user_sessions/current​

The middleware follows three rules when it calls Rails. Each one comes from the three rules on the first page, and each one has a line in the code below.

RuleWhat the middleware doesWhy
No cookie means no callA request with no jodapp_session_id cookie gets null from the getter, and Rails is never calledRails finds a session only through the cookie. Most visitors and every search engine are signed out, so this saves one Rails call on every public page
Nobody asked, no callThe middleware stores a getter and fetches nothing. The first caller starts the request. Every later caller shares the same promiseA click between two public pages never asks, so it costs nothing. One request causes at most one session call, and often zero
Only 401 means signed out200 returns the current-session JSON. 401 returns null. 403, 5xx and a network failure are thrown, and reach the error boundaryAn outage must never look like a logout. A catch that returns null for everything would sign out every visitor at once

A network failure arrives as an ApiError with status: 0, so it falls through to the throw like any other error.

The code​

The whole middleware is short. Every line serves one of the three rules or the cache header.

// app/auth/auth-user-session-middleware.js
import { createContext } from 'react-router'
import { identitiesUserSessionsApi } from '@/api/identities-user-sessions-api'
import { authSessionCookies } from '@/auth/auth-session-cookies'

// The ROUTER context from react-router, not React's createContext. Always pass a
// default value: context.get() throws when nothing was set and there is no default.
const sessionContext = createContext(null)

const sessionMiddleware = async ({ request, context }, next) => {
const cookie = request.headers.get('cookie')
const hasSessionCookie = authSessionCookies.hasSessionCookie(request)

let sessionPromise = null

// Rule 2. Nothing is fetched until something asks. The first caller starts
// the request, and every later caller shares the same promise.
const getIdentitiesUserSessionsCurrent = () => {
if (sessionPromise) return sessionPromise

sessionPromise = (async () => {
// Rule 1. No cookie means nobody is signed in, so Rails is never called.
if (!hasSessionCookie) return null

try {
return await identitiesUserSessionsApi.current({ cookie: cookie })
} catch (error) {
// Rule 3. Only 401 means signed out. Everything else must reach the
// error boundary, so an outage never looks like a logout.
if (error.status === 401) return null

throw error
}
})()

return sessionPromise
}

context.set(sessionContext, { getIdentitiesUserSessionsCurrent: getIdentitiesUserSessionsCurrent })

const response = await next()

// A response built while a session cookie is present must never be stored by a
// shared cache. Every document, .data response and thrown redirect passes through here.
if (hasSessionCookie) {
response.headers.set('Cache-Control', 'private, no-store')
}

return response
}

export const authUserSession = {
context : sessionContext,
middleware : sessionMiddleware
}

Four details in that code are easy to miss.

  • The cookie is read from the request, once, at the top.
    • The cookie cannot change during a request, because Rails sets cookies only at login and logout, and never on a read.
    • Router context holds the session. It never holds the cookie.
  • The Cache-Control header is set after await next().
    • By then the response exists, whatever it is: a document, route data, or a redirect a policy threw.
    • Node must add this header itself. React Router adds no cache header on its own, and the response carries the person's name and access states.
  • authSessionCookies holds the cookie name and answers one question: does this request carry it?
    • The name must match exactly. jodapp_session_id never matches teamjod_session_id, so an admin cookie can never look like a user session.
  • The admin twin in app/auth/auth-admin-session-middleware.js is the same code with teamjod_session_id, teamIdentitiesAdminSessionsApi.current(), and getTeamIdentitiesAdminSessionsCurrent.
    • The two files share nothing, so a wrong-side read is impossible.

Where the session is kept, and who reads it​

The root loader is the only owner of the session. There is one copy, in route data.

// app/roots/public-root.jsx
import { Outlet } from 'react-router'
import { authUserSession } from '@/auth/auth-user-session-middleware'

export const middleware = [authUserSession.middleware]

export const loader = async ({ context }) => {
const { getIdentitiesUserSessionsCurrent } = context.get(authUserSession.context)

return { identitiesUserSession: await getIdentitiesUserSessionsCurrent() }
}

export default function PublicRoot() {
return <Outlet />
}
Root routeReturns
app/roots/public-root.jsx{ identitiesUserSession }
app/roots/team-root.jsx{ identitiesAdminSession }

Node keeps the session for one request and then forgets it.

RuleWhy
The session is memoized per request, never across requestsRouter context dies when the request ends. That is the only cache this design allows
Node has no instance-level session cacheTwo Node instances must stay interchangeable. One would know about a login or logout, and the other would not
Every response built with a session cookie present carries Cache-Control: private, no-storeThe HTML and the .data responses carry the person's name and access states. A shared cache between the browser and Node must never store them. Rails is the boundary explains the two hops

A component reads the session through one hook per identity system.

// app/hooks/use-identities-user-session.js
import { useRouteLoaderData } from 'react-router'

export const useIdentitiesUserSession = () => (
useRouteLoaderData('roots/public-root')?.identitiesUserSession || null
)
  • The route id is the file path without app/ and the extension, so app/roots/public-root.jsx is roots/public-root.
    • The hook exists to hide that string. If the file moves, one line changes.
  • The hook only reads. It returns no function that changes the session.
  • The admin twin is useIdentitiesAdminSession() in app/hooks/use-identities-admin-session.js, reading roots/team-root.

Derive every fact from the one object:

const identitiesUserSession = useIdentitiesUserSession()
const isSignedIn = !!identitiesUserSession?.identities_user?.id
const talentState = identitiesUserSession?.talent_access?.state
const employerState = identitiesUserSession?.employer_access?.state

Policies read the same session through the getter, inside Node, before any page renders. The policy routes page shows how.

When React Router reads it again​

Every later read is the same middleware and the same loader. There is no second path.

  • The root loader runs on every document request, and the copy in the browser is replaced.
  • On a click that enters a policy's area, the policy reads the session on Node through the getter, but the root loader does not run. Node decides with a fresh answer, and the browser keeps the copy it already has.
  • After a route action succeeds, React Router loads route data again on its own, and the root loader runs.
  • After a route action fails with 401 or 403, the root routes opt back in to that reload.
  • After a direct API call is refused, the caller asks React Router to reload with revalidate(), and does nothing else.
  • Only a run of the root loader replaces the copy in the browser. A policy's read never does.
  • React Router revalidation in JodApp Web states each rule with its source. This page does not repeat them.

What must never happen​

These are the mistakes this design exists to prevent.

Do notBecause
Put the session in a React providerTwo owners drift apart. The root loader is the owner
Copy the session into useState or in a useEffectThe copy goes stale the moment the loader runs again
Return an update function from the hookNothing may change the session except a route action. Changing a session covers that
Treat any failure as signed outOnly 401 means signed out. Everything else is an error
Cache a session on Node across requestsNode instances must stay interchangeable
Read the cookie's value, or forward a Set-Cookie headerThe value is a signed id for Rails. Rails sets cookies only at login and logout, so a read never has a cookie to forward
Call GET /identities/user_sessions/current from a component or a clientLoaderThe root loader already holds the answer. A second read is a second owner

React makes the same point about keeping one fact in one place, in Choosing the State Structure.