@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 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. Filtering a record by its *own* id isn't a
148
- compilable AST shape; filter by a unique field value.
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)
@@ -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 || typeof msg.id !== "number")
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.38.0",
3
+ "version": "0.38.1",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {