@lotics/app-sdk 0.46.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +49 -397
- package/dist/src/rpc.js +32 -5
- package/docs/ai.md +201 -0
- package/docs/data_fetching.md +349 -0
- package/docs/files.md +312 -0
- package/docs/members_and_options.md +307 -0
- package/docs/mutations.md +438 -0
- package/docs/navigation_and_state.md +289 -0
- package/docs/queries.md +909 -0
- package/docs/runtime.md +432 -0
- package/docs/security.md +116 -0
- package/package.json +3 -2
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# Navigation & client state
|
|
2
|
+
|
|
3
|
+
How an app's screens and view-state live in the URL, and how small client-side
|
|
4
|
+
state persists across sessions. Covers **`AppRouter`** (multi-screen in-app
|
|
5
|
+
routing from the `@lotics/app-sdk/router` entry), **`useUrlState`** + the
|
|
6
|
+
**`urlParam`** codecs (a typed, declared slice of filters/search/sort/tab kept
|
|
7
|
+
in the address bar), and **`useRecents`** (a localStorage-backed recently-used
|
|
8
|
+
list). Read this when an app has more than one screen, when a filtered view
|
|
9
|
+
must survive refresh or be shareable as a link, or when a picker should
|
|
10
|
+
remember what the user chose last.
|
|
11
|
+
|
|
12
|
+
## The two URL layers
|
|
13
|
+
|
|
14
|
+
The URL carries two independent kinds of app state, each owned by exactly one
|
|
15
|
+
mechanism:
|
|
16
|
+
|
|
17
|
+
| Layer | Owner | What it holds | History behavior |
|
|
18
|
+
| --- | --- | --- | --- |
|
|
19
|
+
| **Routing** (path) | `AppRouter` | Which screen is showing (`/`, `/item/:id`) | Navigations push entries — browser Back/Forward walk screens |
|
|
20
|
+
| **View-state** (query params) | `useUrlState` | Filters, search, sort, active tab | Writes replace in place — never a history entry |
|
|
21
|
+
|
|
22
|
+
They compose: a screen is a path; the screen's filters ride as query params.
|
|
23
|
+
Neither is server state — `useUrlState` values are *client* state that you feed
|
|
24
|
+
into a query's *server* params (see [named-query params](./queries.md) and the
|
|
25
|
+
[data-fetching hooks](./data_fetching.md) they drive). Nothing here is
|
|
26
|
+
persisted server-side; the address bar is the only store.
|
|
27
|
+
|
|
28
|
+
The embedded-vs-standalone distinction below is the runtime's: embedded means
|
|
29
|
+
the app runs in an iframe inside the Lotics host, standalone means it runs on
|
|
30
|
+
its own `<slug>.lotics.app` page (see [runtime](./runtime.md)). Both mechanisms
|
|
31
|
+
expose the same API in both modes with no per-mode code (the few behavioral
|
|
32
|
+
differences — embedded first-paint hydration, cross-screen persistence — are
|
|
33
|
+
flagged below); mode is detected automatically (from the `lotics_host` param
|
|
34
|
+
the host puts on the iframe src — `isEmbedded()` from the main entry exposes it
|
|
35
|
+
if you need it).
|
|
36
|
+
|
|
37
|
+
## `AppRouter` — in-app routing
|
|
38
|
+
|
|
39
|
+
For a multi-screen app, write plain react-router and wrap the route config in
|
|
40
|
+
`AppRouter`:
|
|
41
|
+
|
|
42
|
+
```tsx
|
|
43
|
+
import { AppRouter } from "@lotics/app-sdk/router";
|
|
44
|
+
|
|
45
|
+
export default function App() {
|
|
46
|
+
return (
|
|
47
|
+
<AppRouter
|
|
48
|
+
routes={[
|
|
49
|
+
{ path: "/", element: <ItemList /> },
|
|
50
|
+
{ path: "/item/:id", element: <ItemDetail /> },
|
|
51
|
+
]}
|
|
52
|
+
/>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`routes` is a react-router `RouteObject[]` — nested `children`, `index`
|
|
58
|
+
routes, and splats all work. Inside the tree, use react-router normally:
|
|
59
|
+
`useNavigate`, `useParams`, `useLocation`, `<Link>`, `<Outlet>`.
|
|
60
|
+
|
|
61
|
+
- **Separate entry, optional peer.** `AppRouter` ships from
|
|
62
|
+
`@lotics/app-sdk/router` (signature: `dist/src/router.d.ts`) so apps that
|
|
63
|
+
don't route never pull react-router into their bundle. `react-router-dom`
|
|
64
|
+
(`^7`) is an **optional peer dependency** — an app that imports the router
|
|
65
|
+
entry must install it itself; nothing else in the SDK needs it.
|
|
66
|
+
- **Limitation: element routing only.** `AppRouter` mounts a plain browser
|
|
67
|
+
router, not a react-router *data* router — route `loader`/`action` fields
|
|
68
|
+
are ignored, and `useLoaderData` **throws** ("must be used within a data
|
|
69
|
+
router"). Fetch data inside screen components with the
|
|
70
|
+
[data-fetching hooks](./data_fetching.md).
|
|
71
|
+
|
|
72
|
+
### The URL model
|
|
73
|
+
|
|
74
|
+
The app owns its **own** URL in both modes — the host URL only ever *mirrors*
|
|
75
|
+
the screen, it never drives the router:
|
|
76
|
+
|
|
77
|
+
- **Standalone** (`<slug>.lotics.app`): a normal browser router. Screens are
|
|
78
|
+
real path URLs, browser Back/Forward are native, and deep-links/refresh work
|
|
79
|
+
because the app host serves the entry HTML for any path the build didn't
|
|
80
|
+
emit (SPA fallback).
|
|
81
|
+
|
|
82
|
+
**Limitation:** the SPA fallback only fires for paths containing **no `.`
|
|
83
|
+
anywhere** — a route path with a dot in it (e.g. `/item/v1.2`) navigates
|
|
84
|
+
fine client-side but returns 404 on standalone refresh/deep-link. Keep dots
|
|
85
|
+
out of route paths (put such values in a query param instead).
|
|
86
|
+
|
|
87
|
+
- **Embedded** (inside the Lotics host): the router drives the **iframe's own
|
|
88
|
+
URL** (the iframe is same-origin to itself). The user sees the host's
|
|
89
|
+
address bar, never the iframe's, and the host never sees the iframe's URL —
|
|
90
|
+
so in-app navigation never causes a host navigation and never remounts or
|
|
91
|
+
reloads the app. The iframe's history still participates in the browser's
|
|
92
|
+
session history, so **Back/Forward walk app screens** (and then leave the
|
|
93
|
+
app).
|
|
94
|
+
|
|
95
|
+
### Share and refresh survival (embedded)
|
|
96
|
+
|
|
97
|
+
To keep embedded screens shareable and refresh-survivable, `AppRouter` mirrors
|
|
98
|
+
the current screen (path + query + hash) into the **host** URL under the
|
|
99
|
+
`_loc` query param — a non-remounting `history.replaceState` on the host,
|
|
100
|
+
never a navigation. The mirror is write-only from the app's side; on (re)load
|
|
101
|
+
the host reads `_loc` back and bakes it into the iframe `src`, so the app
|
|
102
|
+
boots *directly* at the saved screen — no extra code in the app.
|
|
103
|
+
|
|
104
|
+
Observable details:
|
|
105
|
+
|
|
106
|
+
- The `lotics_host` handshake param is stripped from the mirrored href — it is
|
|
107
|
+
part of the iframe's boot URL, never part of a route.
|
|
108
|
+
- The host honours `_loc` only when it resolves same-origin to the app;
|
|
109
|
+
anything else (a foreign URL, malformed input) boots the app at its root.
|
|
110
|
+
- Standalone needs none of this — the app's own URL already *is* the screen.
|
|
111
|
+
|
|
112
|
+
**Warning — reserved query keys:** `_loc` (the screen mirror), `lotics_host`
|
|
113
|
+
(the host handshake), and `__mock` (the [mock harness](./runtime.md)) belong
|
|
114
|
+
to the framework. Never declare them in a `useUrlState` shape or write them
|
|
115
|
+
yourself; undeclared keys are already preserved automatically (see below).
|
|
116
|
+
|
|
117
|
+
## `useUrlState` — typed view-state in the address bar
|
|
118
|
+
|
|
119
|
+
Save a declared slice of view-state into the address bar so a filtered view
|
|
120
|
+
survives refresh and is shareable/bookmarkable as a link. Exported from the
|
|
121
|
+
main entry (signature: `dist/src/use_url_state.d.ts`):
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
import { urlParam, useUrlState } from "@lotics/app-sdk";
|
|
125
|
+
|
|
126
|
+
const [filters, setFilters] = useUrlState({
|
|
127
|
+
q: urlParam.string.withDefault(""),
|
|
128
|
+
status: urlParam.enum(["open", "won", "lost"]), // optional → undefined when absent
|
|
129
|
+
tags: urlParam.arrayOf(urlParam.string).withDefault([]),
|
|
130
|
+
page: urlParam.number.withDefault(1),
|
|
131
|
+
});
|
|
132
|
+
// filters: { q: string; status: "open" | "won" | "lost" | undefined; tags: string[]; page: number }
|
|
133
|
+
|
|
134
|
+
setFilters({ status: "won" }); // merge into the address bar → ?status=won
|
|
135
|
+
setFilters({ page: 2 }); // merge; replaces in place — no history entry
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Semantics — each of these is load-bearing:
|
|
139
|
+
|
|
140
|
+
- **The URL is the only store.** `filters` is decoded fresh from the current
|
|
141
|
+
params each render — don't mirror it into `useState`; there is no second
|
|
142
|
+
copy to drift.
|
|
143
|
+
- **Writes replace, never push.** Every `setFilters` is a
|
|
144
|
+
`history.replaceState`-style write — view-state changes never add history
|
|
145
|
+
entries. The back button belongs to routing, not to filters.
|
|
146
|
+
- **Writes are merges over declared keys only.** A patch touches exactly the
|
|
147
|
+
keys it names; every other query param — a key a second `useUrlState` owns,
|
|
148
|
+
the framework's `lotics_host`/`__mock`/`_loc`, a future host param — is read
|
|
149
|
+
past and preserved on write. Multiple `useUrlState` calls with disjoint keys
|
|
150
|
+
coexist without a namespace rule.
|
|
151
|
+
- **Defaults are omitted from the URL.** A value equal to its
|
|
152
|
+
`.withDefault(...)` fallback encodes to nothing — `?page=1` and `?q=` never
|
|
153
|
+
appear, so a shared link carries only what the user actually changed.
|
|
154
|
+
Setting a key back to its default (or an optional key to `undefined`)
|
|
155
|
+
*clears* it from the URL.
|
|
156
|
+
- **Writes are optimistic.** The returned values reflect a write immediately.
|
|
157
|
+
If the underlying address-bar write fails, the failure is logged
|
|
158
|
+
(`console.error`) and the in-session state is kept — only cross-refresh
|
|
159
|
+
persistence is lost.
|
|
160
|
+
- **Back/Forward and hand-edited URLs flow back in.** External URL changes
|
|
161
|
+
re-decode into fresh values automatically in both modes.
|
|
162
|
+
- **Embedded first paint hydrates one tick late.** A standalone app reads its
|
|
163
|
+
own URL synchronously, so the first render already has the URL's values. An
|
|
164
|
+
embedded app cannot read the cross-origin host URL synchronously: the first
|
|
165
|
+
render sees the declared defaults, and the real values arrive right after
|
|
166
|
+
mount over the bridge. Gate an expensive initial fetch accordingly (e.g.
|
|
167
|
+
`enabled`, see [data fetching](./data_fetching.md)) or accept one
|
|
168
|
+
re-render. A user edit made before hydration lands is never clobbered by it.
|
|
169
|
+
- **Declare the shape statically.** Declare it inline, once, with a fixed set
|
|
170
|
+
of keys. The hook reads the codec map through a ref — a shape whose keys or
|
|
171
|
+
codecs change between renders is not re-decoded until the params next
|
|
172
|
+
change.
|
|
173
|
+
- **Debounce text input.** In an embedded app each `setFilters` is a
|
|
174
|
+
cross-frame write. For a search box, keep the live input in local
|
|
175
|
+
`useState` and commit to `setFilters` on a debounce.
|
|
176
|
+
|
|
177
|
+
### `urlParam` codecs
|
|
178
|
+
|
|
179
|
+
Every base codec is *optional*: an absent key decodes to `undefined`, and
|
|
180
|
+
encoding `undefined` clears the key. Chain `.withDefault(fallback)` to make it
|
|
181
|
+
required — absent (or unparseable) decodes to `fallback`, and a value equal to
|
|
182
|
+
`fallback` is kept out of the URL. Equality for `withDefault` compares dates by
|
|
183
|
+
timestamp and arrays element-wise, so `withDefault([])` and
|
|
184
|
+
`withDefault(new Date(...))` behave correctly. Types are in
|
|
185
|
+
`dist/src/url_params.d.ts`.
|
|
186
|
+
|
|
187
|
+
| Builder | Decoded type | URL form | Decode rules |
|
|
188
|
+
| --- | --- | --- | --- |
|
|
189
|
+
| `urlParam.string` | `string` | `?q=text` | Raw text as-is; an empty `?q=` decodes to `""` |
|
|
190
|
+
| `urlParam.number` | `number` | `?page=2` | `Number(...)`; empty or non-finite input → absent |
|
|
191
|
+
| `urlParam.boolean` | `boolean` | `?done=true` | Only the literals `true`/`false`; anything else → absent |
|
|
192
|
+
| `urlParam.isoDate` | `Date` | `?from=2025-04-01` | Strictly `YYYY-MM-DD`; decodes to UTC midnight of that day |
|
|
193
|
+
| `urlParam.enum([...])` | union of the literals | `?status=open` | A value not in the list → absent |
|
|
194
|
+
| `urlParam.arrayOf(inner)` | `T[]` | `?tag=a&tag=b` (repeated key) | A single occurrence decodes to a 1-element array; elements the inner codec rejects are dropped |
|
|
195
|
+
|
|
196
|
+
Behavior notes:
|
|
197
|
+
|
|
198
|
+
- **Unparseable ≡ absent.** A malformed value (letters in a `number`, a bad
|
|
199
|
+
date, an unknown enum literal) decodes exactly like a missing key:
|
|
200
|
+
`undefined` for an optional codec, the fallback for a defaulted one. A
|
|
201
|
+
hand-mangled shared link degrades to defaults, never to a crash.
|
|
202
|
+
- **Repeated keys on a scalar codec** take the first occurrence.
|
|
203
|
+
- **`arrayOf`**: an empty array encodes to nothing — the key is omitted, so an
|
|
204
|
+
empty array and an absent key are indistinguishable (use
|
|
205
|
+
`.withDefault([])` and both decode to `[]`). The inner codec is any *base*
|
|
206
|
+
builder (`string`, `number`, `boolean`, `isoDate`, `enum([...])`).
|
|
207
|
+
**Limitation:** one level only — `arrayOf(arrayOf(...))` silently encodes to
|
|
208
|
+
nothing.
|
|
209
|
+
- **`isoDate` is UTC calendar-date semantics.** It encodes the UTC date of the
|
|
210
|
+
`Date` instant and decodes to UTC midnight. Dates produced by the codec
|
|
211
|
+
round-trip exactly; a `Date` you construct at *local* midnight in a non-UTC
|
|
212
|
+
timezone can encode as the neighbouring day. Construct date-only values in
|
|
213
|
+
UTC, or reuse decoded values.
|
|
214
|
+
|
|
215
|
+
### How `useUrlState` relates to routing
|
|
216
|
+
|
|
217
|
+
`useUrlState` and `AppRouter` own different stores — keep each query key owned
|
|
218
|
+
by exactly one mechanism, and read it only through its owner:
|
|
219
|
+
|
|
220
|
+
- `useUrlState` keys live in the **host** address bar when embedded (written
|
|
221
|
+
over the bridge — the app cannot touch the cross-origin host URL itself) and
|
|
222
|
+
in the page URL when standalone. Read them via the hook, never via
|
|
223
|
+
react-router's `useSearchParams`.
|
|
224
|
+
- Query params you put in a route target (`<Link to="/items?tab=recent">`)
|
|
225
|
+
live in the **router's** location. Read them via react-router. Embedded,
|
|
226
|
+
they are part of the screen mirrored under `_loc`, so they are shareable and
|
|
227
|
+
refresh-survivable too.
|
|
228
|
+
|
|
229
|
+
**Warning — cross-screen persistence differs by mode.** Embedded,
|
|
230
|
+
`useUrlState` keys sit in the host URL, and in-app navigation only merges the
|
|
231
|
+
`_loc` mirror into it, leaving every other key alone — navigate away and back
|
|
232
|
+
and the filters are still in the URL. Standalone, a
|
|
233
|
+
react-router navigation rewrites the whole page URL (path *and* query), which
|
|
234
|
+
drops `useUrlState` keys from the address bar. So treat `useUrlState` state as
|
|
235
|
+
scoped to the screen the hook mounts on; state that must ride along a
|
|
236
|
+
navigation in both modes belongs in the route itself (a path param, or query
|
|
237
|
+
params on the `<Link>` target).
|
|
238
|
+
|
|
239
|
+
## `useRecents` — recently-used items
|
|
240
|
+
|
|
241
|
+
A small, most-recent-first list persisted across sessions — the "recently
|
|
242
|
+
used" affordance a search box or picker shows when focused but empty (pair it
|
|
243
|
+
with a combobox's recent-options slot; see the picker pattern in
|
|
244
|
+
[data fetching](./data_fetching.md)). Exported from the main entry (signature:
|
|
245
|
+
`dist/src/use_recents.d.ts`):
|
|
246
|
+
|
|
247
|
+
```tsx
|
|
248
|
+
const { recents, remember, forget, clear } = useRecents<Item>("item-picker", {
|
|
249
|
+
keyOf: (item) => item.id,
|
|
250
|
+
max: 8,
|
|
251
|
+
});
|
|
252
|
+
// on select:
|
|
253
|
+
remember(item);
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
| Member | Behavior |
|
|
257
|
+
| --- | --- |
|
|
258
|
+
| `recents` | Remembered items, most-recent first, deduped, capped |
|
|
259
|
+
| `remember(item)` | Records `item` as most-recent — moves an existing match (by `keyOf`) to the front, caps at `max`, persists. Call on select. |
|
|
260
|
+
| `forget(item)` | Drops one item, matched by `keyOf` |
|
|
261
|
+
| `clear()` | Empties the list |
|
|
262
|
+
|
|
263
|
+
Options: `keyOf` (stable identity per item — used for dedupe and `forget`;
|
|
264
|
+
default `JSON.stringify`) and `max` (default **5**).
|
|
265
|
+
|
|
266
|
+
- **Storage**: `localStorage`, namespaced per `key` (two pickers on one page
|
|
267
|
+
keep separate lists), so it is per browser profile, per device, and per app
|
|
268
|
+
origin — never synced across devices or users. Passing a different `key` to
|
|
269
|
+
a mounted hook re-hydrates from the new namespace.
|
|
270
|
+
- **Failure-tolerant by design**: if storage is unavailable (private mode,
|
|
271
|
+
quota) or holds corrupt JSON, the hook keeps working in-memory for the
|
|
272
|
+
session — only cross-reload persistence is lost. It never throws.
|
|
273
|
+
- **Items round-trip through JSON.** Store plain serializable values; a `Date`
|
|
274
|
+
inside a remembered item comes back as a string after reload. For objects
|
|
275
|
+
you re-create per render, pass a `keyOf` that returns the item's id — the
|
|
276
|
+
default `JSON.stringify` identity treats any field change as a new item.
|
|
277
|
+
- **Limitation — no cross-tab sync**: another tab's writes are not observed
|
|
278
|
+
live; a tab sees them only when a hook next mounts (or its `key` changes).
|
|
279
|
+
Concurrent tabs last-write-win.
|
|
280
|
+
|
|
281
|
+
## Shipped symbols
|
|
282
|
+
|
|
283
|
+
| Symbol | Entry | Signature |
|
|
284
|
+
| --- | --- | --- |
|
|
285
|
+
| `AppRouter` | `@lotics/app-sdk/router` | `dist/src/router.d.ts` |
|
|
286
|
+
| `useUrlState`, `UrlStateShape`, `UrlStateValues` | `@lotics/app-sdk` | `dist/src/use_url_state.d.ts` |
|
|
287
|
+
| `urlParam`, `UrlParamCodec`, `OptionalUrlParamCodec`, `UrlParams`, `UrlParamValue` | `@lotics/app-sdk` | `dist/src/url_params.d.ts` |
|
|
288
|
+
| `useRecents`, `RecentsApi`, `RecentsOptions` | `@lotics/app-sdk` | `dist/src/use_recents.d.ts` |
|
|
289
|
+
| `isEmbedded` | `@lotics/app-sdk` | `dist/src/rpc.d.ts` |
|