@ascendenceai/cortena-extensions-shared-web
v0.4.0
Published
The web half of the extension boilerplate: URL-first list state, so view, filter, sort, page and selection survive navigation and reload (§8).
Readme
@ascendenceai/cortena-extensions-shared-web
The web half of the extension boilerplate. @ascendenceai/cortena-extensions-shared is the
backend half: it has an Express peer, a Drizzle peer and a cortena-openapi
binary, and its tarball is installed by extensions/*/functions. React does not
belong in it, and no web app imports it today. So the browser code lives here,
next to @ascendenceai/cortena-extensions-web-auth, with React as a peer and react-router as
an optional one.
Today it carries §8 of the protocol: a list's view mode, filters, sort, page and selection survive navigation, Back, reload and being pasted into another tab.
pnpm add @ascendenceai/cortena-extensions-shared-webThe rule
The example that produced it: open Tasks, switch to List, open a task, close it — and you land on Board. One small bug, on every single interaction.
- URL first. View mode, filters, sort, page and everything else that fits go in the search params, so the URL is the view. Nothing that survives a navigation lives in component state.
- The list route owns the state. The detail opens as a nested route over the
list (
/tasks?view=list→/tasks/42?view=list), so the list is never unmounted and the URL is still shareable. A Sheet over the list is allowed; the nested route is the default. - Never
navigate(-1). It breaks deep links, and after a delete it lands the user on the thing they just deleted.useReturnToList()builds a real href. - Only what cannot fit falls back to the browser. Selection can run to
hundreds of ids, so it is kept per user, per route, in
localStorageundercortena.list-state.<routeKey>, and the URL carries a short marker.
useListState(routeKey, schema)
import { useListState } from '@ascendenceai/cortena-extensions-shared-web';
import { z } from 'zod';
const listState = z.object({
view: z.enum(['board', 'list']).default('board'),
q: z.string().default(''),
sort: z.string().default(''), // "name,-createdAt"
page: z.coerce.number().int().min(0).default(0), // zero-based, like TableQuery
pageSize: z.coerce.number().int().min(1).max(200).default(50),
selected: z.array(z.string()).default([]),
});
export function TasksPage() {
const [state, setState] = useListState('tasks', listState, {
store: { selected: 'local' },
});
…
}setState merges: setState({ page: 0 }) leaves the filters where they
are. It also takes an updater — setState((s) => ({ page: s.page + 1 })) — and
a per-call history mode.
The schema is deliberately the same shape as TableQuery (§10), so a list's URL
and its server query are one object rather than two that drift.
Options
| option | what it does |
| --- | --- |
| store | { selected: 'local' } keeps a field in localStorage instead of the URL. .meta({ store: 'local' }) on the field says the same thing where the zod version in use carries metadata. |
| history | 'replace' (default) or 'push' for every write. Replace keeps a filter keystroke out of the history stack, so Back leaves the list rather than unwinding typing one character at a time. Pass { history: 'push' } on a call the user should be able to undo with Back. |
| adapter | The router. Defaults to the provider's, then to window.location + history. |
| storage | Injectable for tests; null turns persistence off. |
| onInvalid | Called with the names of params the schema refused, for a log line. |
| markers | Whether a local field leaves <key>=~<n> in the URL. On by default. |
The encoding
A list URL is a public interface, so the same state produces the same string in every build:
/tasks?view=list&q=alpha+beta&sort=name,-createdAt&page=2&selected=~3- A field equal to its default is absent — a clean URL is the default view, and a shared link carries only what the user actually changed.
- Strings ride raw. Numbers are digits. Booleans are
1/0(true,yes,onare read too, because people type those by hand). - Arrays are a comma list, an item's own comma escaped
\,. This is the encoding §10'sparseTableQueryalready reads. - Anything else is JSON.
- A field held in the browser carries only
~<n>, the count. Opening that link in another browser gives the field's default, not an error. - Keys come out in schema order, and any param the schema does not own is kept after them in the order it arrived.
- A value the schema refuses falls back to that field's default and the rest of
the URL survives — one stale link must never blank a screen. When the whole
object is refused and the schema has a field with no default (a scoped list's
orgId: z.string()), there is no valid empty state to fall back to, so the individually-valid fields are handed back and every one of them is reported toonInvalid. Decoding a URL never throws out of a render.
usePersistedViewState(routeKey, schema)
The half that has no business in a link — column visibility, density, panel widths. Per user, per route, in the browser store, with nothing at all in the URL.
Routers
Every Cortena extension web app is on react-router, so mount the adapter once, inside the router and above the routes:
import { ReactRouterListState } from '@ascendenceai/cortena-extensions-shared-web/react-router';
<BrowserRouter>
<ReactRouterListState>
<Routes>…</Routes>
</ReactRouterListState>
</BrowserRouter>Without a provider the hooks fall back to window.location + history, which
works in an app with no router at all. Anything that can read a location and
write one is an adapter — { pathname, search, push, replace, subscribe? } — so
a test can pass a stub.
Opening a detail
<Route path="tasks" element={<TasksList />}> {/* renders <Outlet /> */}
<Route path=":taskId" element={<TaskDetail />} />
</Route>import { Link } from 'react-router';
import { ListStateLink, useReturnToList } from '@ascendenceai/cortena-extensions-shared-web';
// in the list — the whole list state comes along
<ListStateLink to={`/tasks/${row.id}`} as={Link}>{row.title}</ListStateLink>
// in the detail — the way back, and the post-delete redirect
const back = useReturnToList(); // href, to, search, navigate()
<Button onClick={back.navigate}>Close</Button>
await deleteTask(id); back.navigate();useReturnToList() with no argument goes up one path segment, which is the
nested-route case. A detail that has to be a sibling route takes the origin
in the link (<ListStateLink to="/task/42" params={{ from: '/my-tasks' }}>) and
useReturnToList() reads ?from; or you pass the list path explicitly. What it
never does is navigate(-1).
?from is attacker-controlled — it arrives in a link somebody else wrote — so
it is honoured only when it is a same-origin path: one leading slash, not
followed by another slash or a backslash (isSameOriginPath). //evil.com is a
protocol-relative URL and /\evil.com is the same thing after the browser
normalises the backslash; both look like paths to a startsWith('/') check and
both leave the origin, which is an open redirect on a Close button. Anything
that fails the check is dropped and the parent segment wins.
ListStateLink with no as renders a real anchor: middle-click and "open in a
new tab" give a working URL, which is the entire point of holding the state
there. A plain left click goes through the adapter, so the list is not torn down
and rebuilt.
DataTable
DataTable's server source (§10) takes the same three fields this hook holds,
so the list's URL and its query are one object:
const [state, setState] = useListState('tasks', listState, { store: { selected: 'local' } });
<DataTable
dataSource={{ kind: 'server', fetch: (query, signal) => api.tasksTable(query, signal) }}
query={{ q: state.q, sort: parseSort(state.sort), page: state.page, pageSize: state.pageSize }}
onQueryChange={(query) => setState({ q: query.q ?? '', sort: formatSort(query.sort), page: query.page, pageSize: query.pageSize })}
rowSelection={state.selected}
onRowSelectionChange={(selected) => setState({ selected })}
/>sortrides asname,-createdAtandparseTableQueryon the server reads exactly that, soparseSort/formatSortare the only translation, and a schema field ofz.array(z.object({ id: z.string(), desc: z.boolean() }))removes even that at the cost of a JSON-encoded param.- Reset
page: 0in the samesetStateas a filter change; one write, one history entry, no flash of page 7 of a 2-page result. - Selection goes through the same hook, which is what keeps it out of the link.
Test recipe
src/__tests__ is the recipe, and it is meant to be copied into an extension:
set the view, open a detail, close it, assert unchanged; press Back, assert the
exact prior state; reload, assert unchanged; paste the URL into a new tab,
assert the same view, filter, sort and page; and a URL the schema refuses falls
back to the defaults rather than an empty screen. Audit rule P-09 checks the
same thing from the outside.
Runtime configuration (§19.2, P-27)
// src/config.ts
import { z } from 'zod';
import { readRuntimeConfig } from '@ascendenceai/cortena-extensions-shared-web';
export const config = readRuntimeConfig(
z.object({
AUTH_SERVICE_URL: z.string().url(),
API_BASE_URL: z.string().default('/v1'),
}),
// `pnpm dev` only — and `allowFallback` is what says so out loud.
{ fallback: { AUTH_SERVICE_URL: 'http://localhost:3200' }, allowFallback: import.meta.env.DEV },
);The backend serves /runtime-config.js — one assignment to
window.__CORTENA_RUNTIME_CONFIG__, read out of the pod's environment, which is
the ConfigMap (serveRuntimeConfig in @ascendenceai/cortena-extensions-shared; the Helm
snippet and the checksum/config annotation are in that package's README).
index.html loads it before the module bundle.
Why a schema rather than reading the global directly: the failure it replaces is
silent. A VITE_ variable that was never set becomes the string undefined in
the bundle, and the first symptom is a fetch to undefined/v1/orgs/… three
screens in, blamed on the screen. This throws at boot, naming the key and the
ConfigMap it comes from.
Why fallback rather than reading import.meta.env: pnpm dev has no backend
serving the file, so something has to fill the gap — but reading
import.meta.env here would put the build-time substitution back, and it would
work in production too, which is how it would survive review. A fallback is
plainly a development default, in source, where a reviewer sees it, and the
served file wins over it every time.
Why allowFallback is a second, separate option: an empty
window.__CORTENA_RUNTIME_CONFIG__ is pnpm dev and a pod that lost its
<script> tag, its ConfigMap or its serveRuntimeConfig route. A fallback
alone cannot tell them apart, so the second case boots looking healthy and talks
to localhost from a real browser. An empty global therefore throws unless
the caller passes { allowFallback: true }. It gates only the empty case: a
served file that is missing a key throws either way, because the deployment
plainly meant to configure the app and got it wrong.
