@lotics/app-sdk 0.38.0 → 0.38.1
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 +41 -2
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +2 -0
- package/dist/src/rpc.d.ts +16 -1
- package/dist/src/rpc.js +67 -1
- package/dist/src/url_params.d.ts +93 -0
- package/dist/src/url_params.js +215 -0
- package/dist/src/use_url_state.d.ts +16 -0
- package/dist/src/use_url_state.js +74 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -74,6 +74,10 @@ Pick by intent. (→ open the `.d.ts` for the exact signature.)
|
|
|
74
74
|
client-side advisory — pass `coords` to a workflow to record where an action happened.
|
|
75
75
|
- **Recents** — **`useRecents(key, max)`** → most-recent-first, deduped, capped, `localStorage`-backed
|
|
76
76
|
(web-only, which is why it's here, not in `@lotics/ui`). Feeds a `Combobox`'s `recentOptions`.
|
|
77
|
+
- **Shareable view-state (filters in the URL)** — **`useUrlState(shape)`** → `[values, setValues]`,
|
|
78
|
+
keeping a declared slice of state (filters, search, sort, the active tab) in the **address bar** so a
|
|
79
|
+
view survives refresh and is shareable/bookmarkable; `setValues(patch, { push })` makes it
|
|
80
|
+
back/forward-navigable. Build `shape` from **`urlParam`** codecs. See *Shareable filters in the URL*.
|
|
77
81
|
- **Optimistic mutation glue** — **`useOptimistic()`** → reconcile a workflow mutation against the
|
|
78
82
|
query cache for an interactive (calendar/kanban/grid) app. See the data-bound recipe.
|
|
79
83
|
- **Infra** — **`mount(opts?)`** boots the app + analytics (call once at entry; PostHog is automatic,
|
|
@@ -144,8 +148,13 @@ compose these, don't hand-roll search:
|
|
|
144
148
|
- **`useRecents`** — persist the picked option; pass its list as `recentOptions`.
|
|
145
149
|
|
|
146
150
|
Fetch detail on select with a SECOND parameterized query (a unique-code `equals`, or a link
|
|
147
|
-
`has_any_of [record_id]`) — never a bare full-table load.
|
|
148
|
-
|
|
151
|
+
`has_any_of [record_id]`) — never a bare full-table load. To fetch a record by its **own id**, use the
|
|
152
|
+
field-less **`record_id` system condition** (a sibling of `locked`/`current_member`, NOT a `field_key`):
|
|
153
|
+
`useQuery(alias, {}, { filter: { node_type: "condition", type: "record_id", operator: "is_any_of", value:
|
|
154
|
+
[id] } })` (`is_none_of` excludes). Works on any row-level query; a *grouped* query collapses rows so it's
|
|
155
|
+
rejected there. A link `has_any_of [record_id]` matches a *related* record; `record_id` matches the row's
|
|
156
|
+
own id — the only way, since a record has no field holding its own id. **Prefer a link/join when an actual
|
|
157
|
+
relationship exists**; reach for `record_id` only when your starting point is a bare id (e.g. a drill row).
|
|
149
158
|
|
|
150
159
|
## Browse + sort + filter (the record picker)
|
|
151
160
|
|
|
@@ -241,6 +250,36 @@ stops constraining instead of erroring (no-op for all-required queries).
|
|
|
241
250
|
independently. Keep a scope that must always apply `required` (a missing required param 400s). Typos
|
|
242
251
|
can't widen — deploy rejects a `{{params.x}}` with no declared param.
|
|
243
252
|
|
|
253
|
+
## Shareable filters in the URL (`useUrlState`)
|
|
254
|
+
|
|
255
|
+
The filters above are the query's *server* params; **`useUrlState`** is their *client* home — keep them in
|
|
256
|
+
the address bar and a filtered view survives refresh, is shareable/bookmarkable as a link, and (with
|
|
257
|
+
`{ push: true }`) is back/forward-navigable. The hook drives the host's URL over the bridge; the app
|
|
258
|
+
can't touch the cross-origin host URL itself. Standalone apps drive their own URL — same code.
|
|
259
|
+
|
|
260
|
+
```tsx
|
|
261
|
+
const [filters, setFilters] = useUrlState({
|
|
262
|
+
q: urlParam.string.withDefault(""),
|
|
263
|
+
status: urlParam.enum(["open", "won", "lost"]), // optional → omitted when unset
|
|
264
|
+
tags: urlParam.arrayOf(urlParam.string).withDefault([]),
|
|
265
|
+
page: urlParam.number.withDefault(1),
|
|
266
|
+
});
|
|
267
|
+
// filters → { q: string; status?: "open"|"won"|"lost"; tags: string[]; page: number }
|
|
268
|
+
const { rows } = usePaginatedQuery("search", { keyword: filters.q, status: filters.status }, { pageSize: 50 });
|
|
269
|
+
setFilters({ status: "won" }); // merge, replace history → URL ?status=won
|
|
270
|
+
setFilters({ page: 2 }, { push: true }); // back-able navigation
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
- **`urlParam`** codecs: `string` / `number` / `boolean` / `isoDate` / `enum([...])` / `arrayOf(inner)`,
|
|
274
|
+
each `.withDefault(v)` to make it required. **Defaults are omitted from the URL** (`page=1` never
|
|
275
|
+
appears) — links carry only what changed.
|
|
276
|
+
- **The URL is the only store** — `filters` is decoded fresh each render; don't mirror it into `useState`.
|
|
277
|
+
- **Declared keys only.** The app touches just the keys in `shape`; every other param (a second
|
|
278
|
+
`useUrlState`, the framework's own) is preserved on write — no namespace rule to remember.
|
|
279
|
+
- **Search box:** keep the live input in local `useState` and commit to `setFilters` on a **debounce** —
|
|
280
|
+
in an embedded app each `setFilters` is a cross-frame write. Default is *replace* (no history entry);
|
|
281
|
+
reserve `{ push: true }` for committed navigations (open a detail, switch a major tab).
|
|
282
|
+
|
|
244
283
|
---
|
|
245
284
|
|
|
246
285
|
## Recipes (app actions beyond the hooks)
|
package/dist/src/index.d.ts
CHANGED
|
@@ -39,3 +39,7 @@ export { useOptimistic } from "./use_optimistic.js";
|
|
|
39
39
|
export type { OptimisticApi } from "./use_optimistic.js";
|
|
40
40
|
export { useRecents } from "./use_recents.js";
|
|
41
41
|
export type { RecentsApi, RecentsOptions } from "./use_recents.js";
|
|
42
|
+
export { useUrlState } from "./use_url_state.js";
|
|
43
|
+
export type { UrlStateShape, UrlStateValues, SetUrlStateOptions } from "./use_url_state.js";
|
|
44
|
+
export { urlParam } from "./url_params.js";
|
|
45
|
+
export type { UrlParamCodec, OptionalUrlParamCodec, UrlParams, UrlParamValue, } from "./url_params.js";
|
package/dist/src/index.js
CHANGED
|
@@ -27,3 +27,5 @@ export { readSelect } from "./select.js";
|
|
|
27
27
|
export { row, readLinks, readFiles, readLocked } from "./row.js";
|
|
28
28
|
export { useOptimistic } from "./use_optimistic.js";
|
|
29
29
|
export { useRecents } from "./use_recents.js";
|
|
30
|
+
export { useUrlState } from "./use_url_state.js";
|
|
31
|
+
export { urlParam } from "./url_params.js";
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
|
|
1
2
|
/**
|
|
2
3
|
* RPC bridge for a custom-code app's data operations.
|
|
3
4
|
*
|
|
@@ -18,7 +19,7 @@
|
|
|
18
19
|
* app → host: { id, op, payload }
|
|
19
20
|
* host → app: { id, type: "result", data } | { id, type: "error", message }
|
|
20
21
|
*/
|
|
21
|
-
export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
|
|
22
|
+
export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "urlState.get" | "urlState.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
|
|
22
23
|
/** Payload for starting a streaming agent run. */
|
|
23
24
|
export interface AgentRunPayload {
|
|
24
25
|
alias: string;
|
|
@@ -58,6 +59,20 @@ export interface AppContext {
|
|
|
58
59
|
comments_enabled: boolean;
|
|
59
60
|
}
|
|
60
61
|
export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>;
|
|
62
|
+
/** Read the current app-owned query params — bridged: ask the host; standalone:
|
|
63
|
+
* the page's own query string. */
|
|
64
|
+
export declare function getUrlParams(): Promise<UrlParams>;
|
|
65
|
+
/** Merge `patch` into the query string (each key set, or cleared when its value
|
|
66
|
+
* is `undefined`). `push` adds a history entry (back-able); otherwise it
|
|
67
|
+
* replaces in place — the default, so filter churn doesn't flood history. */
|
|
68
|
+
export declare function setUrlParams(patch: UrlParamsPatch, push: boolean): Promise<void>;
|
|
69
|
+
/** Synchronous best-effort snapshot for first paint. Standalone reads its own
|
|
70
|
+
* URL (no flash); bridged can't read the cross-origin host URL synchronously,
|
|
71
|
+
* so it returns `{}` and the hook hydrates via `getUrlParams()` on mount. */
|
|
72
|
+
export declare function peekUrlParams(): UrlParams;
|
|
73
|
+
/** Subscribe to external query changes — back/forward, or an edited URL.
|
|
74
|
+
* Bridged: the host's `url-state` broadcast; standalone: `popstate`. */
|
|
75
|
+
export declare function subscribeUrlParams(cb: (params: UrlParams) => void): () => void;
|
|
61
76
|
/**
|
|
62
77
|
* Start a streaming agent run. Each raw SSE text chunk is handed to `onText`
|
|
63
78
|
* (the caller parses it via `agent_stream`); `done` settles when the stream
|
package/dist/src/rpc.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { promptForPassword } from "./password_gate.js";
|
|
2
2
|
import { runUploadPipeline } from "./upload/pipeline.js";
|
|
3
|
+
import { parseSearch, serializeMerge, } from "./url_params.js";
|
|
3
4
|
/**
|
|
4
5
|
* The embedding Lotics host's origin — present iff the app is bridged.
|
|
5
6
|
*
|
|
@@ -23,8 +24,49 @@ export function rpc(op, payload) {
|
|
|
23
24
|
? rpcBridged(op, payload, hostOrigin)
|
|
24
25
|
: rpcStandalone(op, payload);
|
|
25
26
|
}
|
|
27
|
+
// ── URL state (the address bar as shareable view-state) ─────────────────────
|
|
28
|
+
//
|
|
29
|
+
// `useUrlState` keeps a declared slice of app state in the host's address bar so
|
|
30
|
+
// a filtered view survives refresh and is shareable/bookmarkable. An embedded
|
|
31
|
+
// app can't read or write the cross-origin host URL directly, so reads/writes
|
|
32
|
+
// flow over the bridge (the host drives its own router); a standalone app owns
|
|
33
|
+
// its top-level URL and the same ops resolve against `window.location`.
|
|
34
|
+
/** Read the current app-owned query params — bridged: ask the host; standalone:
|
|
35
|
+
* the page's own query string. */
|
|
36
|
+
export function getUrlParams() {
|
|
37
|
+
return rpc("urlState.get", {});
|
|
38
|
+
}
|
|
39
|
+
/** Merge `patch` into the query string (each key set, or cleared when its value
|
|
40
|
+
* is `undefined`). `push` adds a history entry (back-able); otherwise it
|
|
41
|
+
* replaces in place — the default, so filter churn doesn't flood history. */
|
|
42
|
+
export function setUrlParams(patch, push) {
|
|
43
|
+
return rpc("urlState.set", { params: patch, push });
|
|
44
|
+
}
|
|
45
|
+
/** Synchronous best-effort snapshot for first paint. Standalone reads its own
|
|
46
|
+
* URL (no flash); bridged can't read the cross-origin host URL synchronously,
|
|
47
|
+
* so it returns `{}` and the hook hydrates via `getUrlParams()` on mount. */
|
|
48
|
+
export function peekUrlParams() {
|
|
49
|
+
return getHostOrigin() ? {} : parseSearch(window.location.search);
|
|
50
|
+
}
|
|
51
|
+
/** Subscribe to external query changes — back/forward, or an edited URL.
|
|
52
|
+
* Bridged: the host's `url-state` broadcast; standalone: `popstate`. */
|
|
53
|
+
export function subscribeUrlParams(cb) {
|
|
54
|
+
if (getHostOrigin()) {
|
|
55
|
+
ensureListener();
|
|
56
|
+
urlStateSubscribers.add(cb);
|
|
57
|
+
return () => {
|
|
58
|
+
urlStateSubscribers.delete(cb);
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const handler = () => cb(parseSearch(window.location.search));
|
|
62
|
+
window.addEventListener("popstate", handler);
|
|
63
|
+
return () => window.removeEventListener("popstate", handler);
|
|
64
|
+
}
|
|
26
65
|
const pending = new Map();
|
|
27
66
|
const streaming = new Map();
|
|
67
|
+
/** `useUrlState` subscribers (bridged transport) — notified when the host pushes
|
|
68
|
+
* a `url-state` broadcast after an external navigation. */
|
|
69
|
+
const urlStateSubscribers = new Set();
|
|
28
70
|
let nextRpcId = 0;
|
|
29
71
|
let listenerInstalled = false;
|
|
30
72
|
function ensureListener() {
|
|
@@ -36,7 +78,16 @@ function ensureListener() {
|
|
|
36
78
|
if (event.source !== window.parent || event.origin !== getHostOrigin())
|
|
37
79
|
return;
|
|
38
80
|
const msg = event.data;
|
|
39
|
-
if (!msg
|
|
81
|
+
if (!msg)
|
|
82
|
+
return;
|
|
83
|
+
// Broadcast (no id): the host pushes the new query params after an external
|
|
84
|
+
// navigation (back/forward, an edited URL) so `useUrlState` re-hydrates.
|
|
85
|
+
if (msg.type === "url-state" && msg.params && typeof msg.params === "object") {
|
|
86
|
+
for (const cb of urlStateSubscribers)
|
|
87
|
+
cb(msg.params);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (typeof msg.id !== "number")
|
|
40
91
|
return;
|
|
41
92
|
// Single-response ops.
|
|
42
93
|
const handler = pending.get(msg.id);
|
|
@@ -328,6 +379,11 @@ function rpcStandalone(op, payload) {
|
|
|
328
379
|
return standaloneContext();
|
|
329
380
|
case "openExternal":
|
|
330
381
|
return standaloneOpenExternal(payload);
|
|
382
|
+
case "urlState.get":
|
|
383
|
+
// Standalone is a top-level page — its own query string IS the store.
|
|
384
|
+
return Promise.resolve(parseSearch(window.location.search));
|
|
385
|
+
case "urlState.set":
|
|
386
|
+
return standaloneUrlStateSet(payload);
|
|
331
387
|
case "comments.list":
|
|
332
388
|
case "comments.create":
|
|
333
389
|
case "comments.update":
|
|
@@ -376,6 +432,16 @@ function openValidatedUrl(url) {
|
|
|
376
432
|
async function standaloneOpenExternal(p) {
|
|
377
433
|
openValidatedUrl(p.url);
|
|
378
434
|
}
|
|
435
|
+
async function standaloneUrlStateSet(p) {
|
|
436
|
+
const search = serializeMerge(window.location.search, p.params ?? {});
|
|
437
|
+
const url = window.location.pathname + (search ? `?${search}` : "") + window.location.hash;
|
|
438
|
+
// pushState/replaceState don't fire popstate, so subscribers aren't notified
|
|
439
|
+
// here — the hook updates its own state optimistically on write.
|
|
440
|
+
if (p.push)
|
|
441
|
+
window.history.pushState(null, "", url);
|
|
442
|
+
else
|
|
443
|
+
window.history.replaceState(null, "", url);
|
|
444
|
+
}
|
|
379
445
|
async function standaloneContext() {
|
|
380
446
|
const info = await resolveAppInfo();
|
|
381
447
|
return {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed codecs that map URL query-string values (always strings, or string
|
|
3
|
+
* arrays for repeated keys) to and from the typed view-state an app keeps in
|
|
4
|
+
* the address bar — the engine behind `useUrlState`.
|
|
5
|
+
*
|
|
6
|
+
* Two properties make the URLs clean and the round-trip lossless:
|
|
7
|
+
*
|
|
8
|
+
* - **Default-omission.** A codec built with `.withDefault(d)` encodes the
|
|
9
|
+
* default value to `undefined` — i.e. the key is dropped from the URL. So a
|
|
10
|
+
* filter at its default (`page=1`, `q=""`) never appears, and the shared
|
|
11
|
+
* link carries only what the user actually changed.
|
|
12
|
+
* - **Declared keys only.** `decodeAll`/`encodePatch` touch exactly the keys the
|
|
13
|
+
* app declared. Every other query param (`lotics_host`, `__mock`, a param a
|
|
14
|
+
* second `useUrlState` owns, a future host param) is read past and preserved
|
|
15
|
+
* on write — the merge is the namespace boundary, so no reserved-prefix rule
|
|
16
|
+
* is needed.
|
|
17
|
+
*
|
|
18
|
+
* Self-contained on purpose: the SDK publishes to npm as a bundle-free `dist/`,
|
|
19
|
+
* so it carries no workspace deps (no `@lotics/shared`). These are plain pure
|
|
20
|
+
* functions — a frontend that later wants the same value⇄query codec can import
|
|
21
|
+
* them from here rather than growing a second copy.
|
|
22
|
+
*/
|
|
23
|
+
/** A query value as it appears in the URL: a single string, or an array for a
|
|
24
|
+
* repeated key (`?tag=a&tag=b`). Absent keys are simply missing from the map. */
|
|
25
|
+
export type UrlParamValue = string | string[];
|
|
26
|
+
/** The current query string decoded to a map (present keys only). */
|
|
27
|
+
export type UrlParams = Record<string, UrlParamValue>;
|
|
28
|
+
/** A write: each key set to its encoded value, or `undefined` to clear it. Only
|
|
29
|
+
* the keys present are touched; others in the URL are preserved (a merge). */
|
|
30
|
+
export type UrlParamsPatch = Record<string, UrlParamValue | undefined>;
|
|
31
|
+
/**
|
|
32
|
+
* A reversible mapping between a typed value `T` and its URL representation.
|
|
33
|
+
* Methods (not function-typed properties) so a concrete `UrlParamCodec<string>`
|
|
34
|
+
* stays assignable to `UrlParamCodec<unknown>` for the `useUrlState` codec map.
|
|
35
|
+
*/
|
|
36
|
+
export interface UrlParamCodec<T> {
|
|
37
|
+
/** Raw query value (or `undefined` when the key is absent) → typed value. */
|
|
38
|
+
decode(raw: UrlParamValue | undefined): T;
|
|
39
|
+
/** Typed value → raw query value, or `undefined` to omit the key. */
|
|
40
|
+
encode(value: T): UrlParamValue | undefined;
|
|
41
|
+
}
|
|
42
|
+
/** A codec whose value is optional (absent key → `undefined`), refinable to a
|
|
43
|
+
* required codec with a fallback via `.withDefault`. */
|
|
44
|
+
export interface OptionalUrlParamCodec<T> extends UrlParamCodec<T | undefined> {
|
|
45
|
+
/** Make the value required: an absent key decodes to `fallback`, and a value
|
|
46
|
+
* equal to `fallback` encodes to nothing (kept out of the URL). */
|
|
47
|
+
withDefault(fallback: T): UrlParamCodec<T>;
|
|
48
|
+
}
|
|
49
|
+
declare function enumCodec<const V extends readonly string[]>(values: V): OptionalUrlParamCodec<V[number]>;
|
|
50
|
+
declare function arrayOf<T>(inner: UrlParamCodec<T | undefined>): OptionalUrlParamCodec<T[]>;
|
|
51
|
+
/**
|
|
52
|
+
* The codec builders an app composes into a `useUrlState` shape. Each base
|
|
53
|
+
* builder yields an optional codec (absent key → `undefined`); add
|
|
54
|
+
* `.withDefault(v)` to make it required and keep the default out of the URL.
|
|
55
|
+
*
|
|
56
|
+
* useUrlState({
|
|
57
|
+
* q: urlParam.string.withDefault(""),
|
|
58
|
+
* status: urlParam.enum(["open", "won", "lost"]), // optional
|
|
59
|
+
* tags: urlParam.arrayOf(urlParam.string).withDefault([]),
|
|
60
|
+
* from: urlParam.isoDate, // optional Date
|
|
61
|
+
* page: urlParam.number.withDefault(1),
|
|
62
|
+
* })
|
|
63
|
+
*/
|
|
64
|
+
export declare const urlParam: {
|
|
65
|
+
readonly string: OptionalUrlParamCodec<string>;
|
|
66
|
+
readonly number: OptionalUrlParamCodec<number>;
|
|
67
|
+
readonly boolean: OptionalUrlParamCodec<boolean>;
|
|
68
|
+
readonly isoDate: OptionalUrlParamCodec<Date>;
|
|
69
|
+
readonly enum: typeof enumCodec;
|
|
70
|
+
readonly arrayOf: typeof arrayOf;
|
|
71
|
+
};
|
|
72
|
+
/** Decode the declared keys out of a params map into typed values. */
|
|
73
|
+
export declare function decodeAll<D extends Record<string, UrlParamCodec<unknown>>>(defs: D, params: UrlParams): {
|
|
74
|
+
[K in keyof D]: D[K] extends UrlParamCodec<infer T> ? T : never;
|
|
75
|
+
};
|
|
76
|
+
/** Encode a partial set of declared values into a patch (each key → value or
|
|
77
|
+
* `undefined` to clear). Only the keys present in `values` are emitted, so the
|
|
78
|
+
* write merges and leaves every other query param untouched. */
|
|
79
|
+
export declare function encodePatch<D extends Record<string, UrlParamCodec<unknown>>>(defs: D, values: Partial<{
|
|
80
|
+
[K in keyof D]: D[K] extends UrlParamCodec<infer T> ? T : never;
|
|
81
|
+
}>): UrlParamsPatch;
|
|
82
|
+
/** Parse a `location.search` string into a params map (repeated keys → array). */
|
|
83
|
+
export declare function parseSearch(search: string): UrlParams;
|
|
84
|
+
/** Apply a patch to a `location.search` string and return the new query string
|
|
85
|
+
* (no leading `?`). Each patch key is replaced; `undefined` clears it; every
|
|
86
|
+
* other existing param is preserved. */
|
|
87
|
+
export declare function serializeMerge(search: string, patch: UrlParamsPatch): string;
|
|
88
|
+
/** Apply a patch to an in-memory params map (the optimistic local mirror). */
|
|
89
|
+
export declare function applyPatch(params: UrlParams, patch: UrlParamsPatch): UrlParams;
|
|
90
|
+
/** Shallow value-equality over two params maps — used to drop echoed updates so
|
|
91
|
+
* an app's own write doesn't re-render it a second time. */
|
|
92
|
+
export declare function paramsEqual(a: UrlParams, b: UrlParams): boolean;
|
|
93
|
+
export {};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed codecs that map URL query-string values (always strings, or string
|
|
3
|
+
* arrays for repeated keys) to and from the typed view-state an app keeps in
|
|
4
|
+
* the address bar — the engine behind `useUrlState`.
|
|
5
|
+
*
|
|
6
|
+
* Two properties make the URLs clean and the round-trip lossless:
|
|
7
|
+
*
|
|
8
|
+
* - **Default-omission.** A codec built with `.withDefault(d)` encodes the
|
|
9
|
+
* default value to `undefined` — i.e. the key is dropped from the URL. So a
|
|
10
|
+
* filter at its default (`page=1`, `q=""`) never appears, and the shared
|
|
11
|
+
* link carries only what the user actually changed.
|
|
12
|
+
* - **Declared keys only.** `decodeAll`/`encodePatch` touch exactly the keys the
|
|
13
|
+
* app declared. Every other query param (`lotics_host`, `__mock`, a param a
|
|
14
|
+
* second `useUrlState` owns, a future host param) is read past and preserved
|
|
15
|
+
* on write — the merge is the namespace boundary, so no reserved-prefix rule
|
|
16
|
+
* is needed.
|
|
17
|
+
*
|
|
18
|
+
* Self-contained on purpose: the SDK publishes to npm as a bundle-free `dist/`,
|
|
19
|
+
* so it carries no workspace deps (no `@lotics/shared`). These are plain pure
|
|
20
|
+
* functions — a frontend that later wants the same value⇄query codec can import
|
|
21
|
+
* them from here rather than growing a second copy.
|
|
22
|
+
*/
|
|
23
|
+
function first(raw) {
|
|
24
|
+
return Array.isArray(raw) ? raw[0] : raw;
|
|
25
|
+
}
|
|
26
|
+
function valueEqual(a, b) {
|
|
27
|
+
if (a === b)
|
|
28
|
+
return true;
|
|
29
|
+
if (a instanceof Date && b instanceof Date)
|
|
30
|
+
return a.getTime() === b.getTime();
|
|
31
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
32
|
+
return a.length === b.length && a.every((x, i) => valueEqual(x, b[i]));
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
/** Wrap an optional base codec with `.withDefault`. */
|
|
37
|
+
function optional(base) {
|
|
38
|
+
return {
|
|
39
|
+
decode: base.decode,
|
|
40
|
+
encode: base.encode,
|
|
41
|
+
withDefault(fallback) {
|
|
42
|
+
return {
|
|
43
|
+
decode: (raw) => {
|
|
44
|
+
const v = base.decode(raw);
|
|
45
|
+
return v === undefined ? fallback : v;
|
|
46
|
+
},
|
|
47
|
+
encode: (value) => (valueEqual(value, fallback) ? undefined : base.encode(value)),
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const stringCodec = optional({
|
|
53
|
+
decode: (raw) => (raw === undefined ? undefined : first(raw)),
|
|
54
|
+
encode: (v) => v,
|
|
55
|
+
});
|
|
56
|
+
const numberCodec = optional({
|
|
57
|
+
decode: (raw) => {
|
|
58
|
+
const s = first(raw);
|
|
59
|
+
if (s === undefined || s === "")
|
|
60
|
+
return undefined;
|
|
61
|
+
const n = Number(s);
|
|
62
|
+
return Number.isFinite(n) ? n : undefined;
|
|
63
|
+
},
|
|
64
|
+
encode: (v) => (v === undefined ? undefined : String(v)),
|
|
65
|
+
});
|
|
66
|
+
const booleanCodec = optional({
|
|
67
|
+
decode: (raw) => {
|
|
68
|
+
const s = first(raw);
|
|
69
|
+
return s === "true" ? true : s === "false" ? false : undefined;
|
|
70
|
+
},
|
|
71
|
+
encode: (v) => (v === undefined ? undefined : v ? "true" : "false"),
|
|
72
|
+
});
|
|
73
|
+
const isoDateCodec = optional({
|
|
74
|
+
decode: (raw) => {
|
|
75
|
+
const s = first(raw);
|
|
76
|
+
if (s === undefined || !/^\d{4}-\d{2}-\d{2}$/.test(s))
|
|
77
|
+
return undefined;
|
|
78
|
+
const d = new Date(`${s}T00:00:00.000Z`);
|
|
79
|
+
return Number.isNaN(d.getTime()) ? undefined : d;
|
|
80
|
+
},
|
|
81
|
+
encode: (v) => (v === undefined ? undefined : v.toISOString().slice(0, 10)),
|
|
82
|
+
});
|
|
83
|
+
function enumCodec(values) {
|
|
84
|
+
return optional({
|
|
85
|
+
decode: (raw) => {
|
|
86
|
+
const s = first(raw);
|
|
87
|
+
return s === undefined ? undefined : values.find((v) => v === s);
|
|
88
|
+
},
|
|
89
|
+
encode: (v) => v,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function arrayOf(inner) {
|
|
93
|
+
return optional({
|
|
94
|
+
decode: (raw) => {
|
|
95
|
+
if (raw === undefined)
|
|
96
|
+
return undefined;
|
|
97
|
+
const items = Array.isArray(raw) ? raw : [raw];
|
|
98
|
+
return items.map((x) => inner.decode(x)).filter((x) => x !== undefined);
|
|
99
|
+
},
|
|
100
|
+
encode: (value) => {
|
|
101
|
+
if (value === undefined || value.length === 0)
|
|
102
|
+
return undefined;
|
|
103
|
+
const out = value
|
|
104
|
+
.map((x) => inner.encode(x))
|
|
105
|
+
.filter((x) => typeof x === "string");
|
|
106
|
+
return out.length > 0 ? out : undefined;
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The codec builders an app composes into a `useUrlState` shape. Each base
|
|
112
|
+
* builder yields an optional codec (absent key → `undefined`); add
|
|
113
|
+
* `.withDefault(v)` to make it required and keep the default out of the URL.
|
|
114
|
+
*
|
|
115
|
+
* useUrlState({
|
|
116
|
+
* q: urlParam.string.withDefault(""),
|
|
117
|
+
* status: urlParam.enum(["open", "won", "lost"]), // optional
|
|
118
|
+
* tags: urlParam.arrayOf(urlParam.string).withDefault([]),
|
|
119
|
+
* from: urlParam.isoDate, // optional Date
|
|
120
|
+
* page: urlParam.number.withDefault(1),
|
|
121
|
+
* })
|
|
122
|
+
*/
|
|
123
|
+
export const urlParam = {
|
|
124
|
+
string: stringCodec,
|
|
125
|
+
number: numberCodec,
|
|
126
|
+
boolean: booleanCodec,
|
|
127
|
+
isoDate: isoDateCodec,
|
|
128
|
+
enum: enumCodec,
|
|
129
|
+
arrayOf,
|
|
130
|
+
};
|
|
131
|
+
/** Decode the declared keys out of a params map into typed values. */
|
|
132
|
+
export function decodeAll(defs, params) {
|
|
133
|
+
const out = {};
|
|
134
|
+
for (const key of Object.keys(defs)) {
|
|
135
|
+
out[key] = defs[key].decode(params[key]);
|
|
136
|
+
}
|
|
137
|
+
// Boundary: a per-key loop can't be expressed as the mapped result type.
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
/** Encode a partial set of declared values into a patch (each key → value or
|
|
141
|
+
* `undefined` to clear). Only the keys present in `values` are emitted, so the
|
|
142
|
+
* write merges and leaves every other query param untouched. */
|
|
143
|
+
export function encodePatch(defs, values) {
|
|
144
|
+
const out = {};
|
|
145
|
+
for (const key of Object.keys(values)) {
|
|
146
|
+
const codec = defs[key];
|
|
147
|
+
if (codec)
|
|
148
|
+
out[key] = codec.encode(values[key]);
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
/** Parse a `location.search` string into a params map (repeated keys → array). */
|
|
153
|
+
export function parseSearch(search) {
|
|
154
|
+
const sp = new URLSearchParams(search);
|
|
155
|
+
const out = {};
|
|
156
|
+
for (const key of sp.keys()) {
|
|
157
|
+
if (key in out)
|
|
158
|
+
continue;
|
|
159
|
+
const all = sp.getAll(key);
|
|
160
|
+
out[key] = all.length > 1 ? all : all[0];
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
/** Apply a patch to a `location.search` string and return the new query string
|
|
165
|
+
* (no leading `?`). Each patch key is replaced; `undefined` clears it; every
|
|
166
|
+
* other existing param is preserved. */
|
|
167
|
+
export function serializeMerge(search, patch) {
|
|
168
|
+
const sp = new URLSearchParams(search);
|
|
169
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
170
|
+
sp.delete(key);
|
|
171
|
+
if (value === undefined)
|
|
172
|
+
continue;
|
|
173
|
+
if (Array.isArray(value)) {
|
|
174
|
+
for (const item of value)
|
|
175
|
+
sp.append(key, item);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
sp.append(key, value);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return sp.toString();
|
|
182
|
+
}
|
|
183
|
+
/** Apply a patch to an in-memory params map (the optimistic local mirror). */
|
|
184
|
+
export function applyPatch(params, patch) {
|
|
185
|
+
const next = { ...params };
|
|
186
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
187
|
+
if (value === undefined)
|
|
188
|
+
delete next[key];
|
|
189
|
+
else
|
|
190
|
+
next[key] = value;
|
|
191
|
+
}
|
|
192
|
+
return next;
|
|
193
|
+
}
|
|
194
|
+
/** Shallow value-equality over two params maps — used to drop echoed updates so
|
|
195
|
+
* an app's own write doesn't re-render it a second time. */
|
|
196
|
+
export function paramsEqual(a, b) {
|
|
197
|
+
const ak = Object.keys(a);
|
|
198
|
+
const bk = Object.keys(b);
|
|
199
|
+
if (ak.length !== bk.length)
|
|
200
|
+
return false;
|
|
201
|
+
for (const key of ak) {
|
|
202
|
+
const av = a[key];
|
|
203
|
+
const bv = b[key];
|
|
204
|
+
if (Array.isArray(av) || Array.isArray(bv)) {
|
|
205
|
+
if (!Array.isArray(av) || !Array.isArray(bv))
|
|
206
|
+
return false;
|
|
207
|
+
if (av.length !== bv.length || !av.every((x, i) => x === bv[i]))
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
else if (av !== bv) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type UrlParamCodec } from "./url_params.js";
|
|
2
|
+
/** A `useUrlState` shape: a map of param key → codec. */
|
|
3
|
+
export type UrlStateShape = Record<string, UrlParamCodec<unknown>>;
|
|
4
|
+
/** The decoded, typed values for a shape. */
|
|
5
|
+
export type UrlStateValues<D extends UrlStateShape> = {
|
|
6
|
+
[K in keyof D]: D[K] extends UrlParamCodec<infer T> ? T : never;
|
|
7
|
+
};
|
|
8
|
+
export interface SetUrlStateOptions {
|
|
9
|
+
/** Add a browser history entry (back/forward navigable). Default `false` —
|
|
10
|
+
* replace in place, so high-frequency filter changes don't flood history. */
|
|
11
|
+
push?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function useUrlState<D extends UrlStateShape>(defs: D): readonly [
|
|
14
|
+
UrlStateValues<D>,
|
|
15
|
+
(patch: Partial<UrlStateValues<D>>, options?: SetUrlStateOptions) => void
|
|
16
|
+
];
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep a declared slice of an app's view-state — filters, search, sort, the
|
|
3
|
+
* active tab — in the browser's address bar, with two-way sync. The result is
|
|
4
|
+
* real browser behaviour for an otherwise opaque cross-origin app: a filtered
|
|
5
|
+
* view survives refresh, is shareable/bookmarkable as a link, and (with
|
|
6
|
+
* `{ push: true }`) is back/forward-navigable.
|
|
7
|
+
*
|
|
8
|
+
* const [filters, setFilters] = useUrlState({
|
|
9
|
+
* q: urlParam.string.withDefault(""),
|
|
10
|
+
* status: urlParam.enum(["open", "won", "lost"]), // optional
|
|
11
|
+
* tags: urlParam.arrayOf(urlParam.string).withDefault([]),
|
|
12
|
+
* page: urlParam.number.withDefault(1),
|
|
13
|
+
* });
|
|
14
|
+
* // filters → { q: string; status?: "open"|"won"|"lost"; tags: string[]; page: number }
|
|
15
|
+
* setFilters({ q: "acme" }); // merge, replace history
|
|
16
|
+
* setFilters({ status: "won" }, { push: true }); // back-able navigation
|
|
17
|
+
*
|
|
18
|
+
* The app declares only the keys it owns; every other query param (a param a
|
|
19
|
+
* second `useUrlState` owns, the framework's `lotics_host`/`__mock`, a future
|
|
20
|
+
* host param) is read past and preserved on write. For a search box, keep the
|
|
21
|
+
* live input in local state and commit to `setFilters` on a debounce — each
|
|
22
|
+
* call is a cross-frame write in an embedded app.
|
|
23
|
+
*
|
|
24
|
+
* The address bar is the only store: nothing is persisted server-side. Values
|
|
25
|
+
* are decoded fresh from the current params each render; standalone reads the
|
|
26
|
+
* URL directly, while an embedded app keeps a local mirror the SDK syncs from
|
|
27
|
+
* the host (initial `urlState.get`, the app's own writes, and back/forward
|
|
28
|
+
* broadcasts) — so there's no independently-mutable second copy to drift.
|
|
29
|
+
*/
|
|
30
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
31
|
+
import { getUrlParams, peekUrlParams, setUrlParams, subscribeUrlParams } from "./rpc.js";
|
|
32
|
+
import { applyPatch, decodeAll, encodePatch, paramsEqual, } from "./url_params.js";
|
|
33
|
+
export function useUrlState(defs) {
|
|
34
|
+
// `defs` is expected stable (declared inline once); read through a ref so the
|
|
35
|
+
// decoded values and `setValues` stay referentially stable across renders.
|
|
36
|
+
const defsRef = useRef(defs);
|
|
37
|
+
defsRef.current = defs;
|
|
38
|
+
const [params, setParams] = useState(peekUrlParams);
|
|
39
|
+
// Once the user edits, a late initial hydration (bridged `getUrlParams`
|
|
40
|
+
// resolves a tick after mount) must not clobber their write.
|
|
41
|
+
const editedRef = useRef(false);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
let active = true;
|
|
44
|
+
const apply = (next) => {
|
|
45
|
+
if (active)
|
|
46
|
+
setParams((prev) => (paramsEqual(prev, next) ? prev : next));
|
|
47
|
+
};
|
|
48
|
+
void getUrlParams().then((p) => {
|
|
49
|
+
if (active && !editedRef.current)
|
|
50
|
+
apply(p);
|
|
51
|
+
});
|
|
52
|
+
const unsubscribe = subscribeUrlParams(apply);
|
|
53
|
+
return () => {
|
|
54
|
+
active = false;
|
|
55
|
+
unsubscribe();
|
|
56
|
+
};
|
|
57
|
+
}, []);
|
|
58
|
+
const values = useMemo(() => decodeAll(defsRef.current, params), [params]);
|
|
59
|
+
const setValues = useCallback((patch, options) => {
|
|
60
|
+
editedRef.current = true;
|
|
61
|
+
const encoded = encodePatch(defsRef.current, patch);
|
|
62
|
+
// Optimistic local mirror so the UI is responsive even before the write
|
|
63
|
+
// round-trips (and the only update path in standalone, where the history
|
|
64
|
+
// write fires no event).
|
|
65
|
+
setParams((prev) => applyPatch(prev, encoded));
|
|
66
|
+
// The optimistic mirror already reflects the change, so a failed write
|
|
67
|
+
// only loses cross-refresh persistence, never the session — keep the
|
|
68
|
+
// optimistic state, but surface the failure instead of swallowing it.
|
|
69
|
+
void setUrlParams(encoded, options?.push ?? false).catch((err) => {
|
|
70
|
+
console.error("useUrlState: failed to write state to the address bar", err);
|
|
71
|
+
});
|
|
72
|
+
}, []);
|
|
73
|
+
return [values, setValues];
|
|
74
|
+
}
|