@lotics/app-sdk 0.38.1 → 0.40.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 +41 -11
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/router.d.ts +4 -0
- package/dist/src/router.js +126 -0
- package/dist/src/rpc.d.ts +18 -7
- package/dist/src/rpc.js +45 -19
- package/dist/src/use_url_state.d.ts +1 -9
- package/dist/src/use_url_state.js +11 -12
- package/package.json +18 -4
package/AGENTS.md
CHANGED
|
@@ -76,8 +76,11 @@ Pick by intent. (→ open the `.d.ts` for the exact signature.)
|
|
|
76
76
|
(web-only, which is why it's here, not in `@lotics/ui`). Feeds a `Combobox`'s `recentOptions`.
|
|
77
77
|
- **Shareable view-state (filters in the URL)** — **`useUrlState(shape)`** → `[values, setValues]`,
|
|
78
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
|
|
80
|
-
|
|
79
|
+
view survives refresh and is shareable/bookmarkable. `setValues` replaces in place. Build `shape` from
|
|
80
|
+
**`urlParam`** codecs. See *Save view-state to the URL*.
|
|
81
|
+
- **In-app routing** — **`AppRouter`** (from `@lotics/app-sdk/router`) → wrap a react-router route config so
|
|
82
|
+
screens become real, addressable URLs in both modes (browser back/forward walk app screens embedded).
|
|
83
|
+
See *In-app navigation*.
|
|
81
84
|
- **Optimistic mutation glue** — **`useOptimistic()`** → reconcile a workflow mutation against the
|
|
82
85
|
query cache for an interactive (calendar/kanban/grid) app. See the data-bound recipe.
|
|
83
86
|
- **Infra** — **`mount(opts?)`** boots the app + analytics (call once at entry; PostHog is automatic,
|
|
@@ -250,12 +253,13 @@ stops constraining instead of erroring (no-op for all-required queries).
|
|
|
250
253
|
independently. Keep a scope that must always apply `required` (a missing required param 400s). Typos
|
|
251
254
|
can't widen — deploy rejects a `{{params.x}}` with no declared param.
|
|
252
255
|
|
|
253
|
-
##
|
|
256
|
+
## Save view-state to the URL (`useUrlState`)
|
|
254
257
|
|
|
255
|
-
The filters above are the query's *server* params; **`useUrlState`** is their *client* home —
|
|
256
|
-
the address bar and a filtered view survives refresh
|
|
257
|
-
|
|
258
|
-
|
|
258
|
+
The filters above are the query's *server* params; **`useUrlState`** is their *client* home — save them to
|
|
259
|
+
the host's address bar and a filtered view survives refresh and is shareable/bookmarkable as a link. Writes
|
|
260
|
+
always **replace** in place (filter changes shouldn't add history entries); screen-to-screen navigation and
|
|
261
|
+
history are the router's job (next section). The hook drives the host's URL over the bridge; the app can't
|
|
262
|
+
touch the cross-origin host URL itself. Standalone apps drive their own URL — same code.
|
|
259
263
|
|
|
260
264
|
```tsx
|
|
261
265
|
const [filters, setFilters] = useUrlState({
|
|
@@ -266,8 +270,8 @@ const [filters, setFilters] = useUrlState({
|
|
|
266
270
|
});
|
|
267
271
|
// filters → { q: string; status?: "open"|"won"|"lost"; tags: string[]; page: number }
|
|
268
272
|
const { rows } = usePaginatedQuery("search", { keyword: filters.q, status: filters.status }, { pageSize: 50 });
|
|
269
|
-
setFilters({ status: "won" }); // merge
|
|
270
|
-
setFilters({ page: 2 }
|
|
273
|
+
setFilters({ status: "won" }); // merge into the address bar → ?status=won
|
|
274
|
+
setFilters({ page: 2 }); // merge (replace; no history entry)
|
|
271
275
|
```
|
|
272
276
|
|
|
273
277
|
- **`urlParam`** codecs: `string` / `number` / `boolean` / `isoDate` / `enum([...])` / `arrayOf(inner)`,
|
|
@@ -277,8 +281,34 @@ setFilters({ page: 2 }, { push: true }); // back-able navigation
|
|
|
277
281
|
- **Declared keys only.** The app touches just the keys in `shape`; every other param (a second
|
|
278
282
|
`useUrlState`, the framework's own) is preserved on write — no namespace rule to remember.
|
|
279
283
|
- **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.
|
|
281
|
-
|
|
284
|
+
in an embedded app each `setFilters` is a cross-frame write.
|
|
285
|
+
|
|
286
|
+
## In-app navigation (`AppRouter`)
|
|
287
|
+
|
|
288
|
+
For a multi-screen app, write plain react-router and wrap your routes in **`AppRouter`** (from
|
|
289
|
+
`@lotics/app-sdk/router`) — it makes screens real, addressable URLs in both modes, with no per-mode code:
|
|
290
|
+
|
|
291
|
+
```tsx
|
|
292
|
+
import { AppRouter } from "@lotics/app-sdk/router";
|
|
293
|
+
|
|
294
|
+
export default function App() {
|
|
295
|
+
return <AppRouter routes={[
|
|
296
|
+
{ path: "/", element: <List /> },
|
|
297
|
+
{ path: "/item/:id", element: <Detail /> },
|
|
298
|
+
]} />;
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Inside, use react-router normally — `useNavigate`, `useParams`, `<Link>`.
|
|
303
|
+
|
|
304
|
+
- **Embedded** (in the Lotics host): the current screen lives in the host address bar (`?_loc=…`) —
|
|
305
|
+
shareable + refresh-survivable — and the **browser Back/Forward buttons walk app screens** (then leave the
|
|
306
|
+
app). The host owns the single history stack; the adapter proxies push/replace/go over the bridge.
|
|
307
|
+
- **Standalone** (`<slug>.lotics.app`): a normal browser router — real path URLs, native browser back/forward.
|
|
308
|
+
|
|
309
|
+
`useUrlState` is the complement for *filters/search* you want as first-class, typed query keys; it composes
|
|
310
|
+
with the router (different keys; the host merge preserves both). `react-router-dom` is an optional peer — only
|
|
311
|
+
apps that import `@lotics/app-sdk/router` pull it in.
|
|
282
312
|
|
|
283
313
|
---
|
|
284
314
|
|
package/dist/src/index.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export type { AppComment, AppCommentFile, CommentsState, UseCommentsArgs, Commen
|
|
|
23
23
|
export { useViewer } from "./viewer.js";
|
|
24
24
|
export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
|
|
25
25
|
export type { GeofenceZone, GeoCoords, GeofenceOutcome, GeofenceOptions } from "./geolocation.js";
|
|
26
|
-
export { rpc } from "./rpc.js";
|
|
26
|
+
export { rpc, isEmbedded } from "./rpc.js";
|
|
27
27
|
export type { RpcOp } from "./rpc.js";
|
|
28
28
|
export { openExternal } from "./open_external.js";
|
|
29
29
|
export { downloadFile } from "./download.js";
|
|
@@ -40,6 +40,6 @@ 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
42
|
export { useUrlState } from "./use_url_state.js";
|
|
43
|
-
export type { UrlStateShape, UrlStateValues
|
|
43
|
+
export type { UrlStateShape, UrlStateValues } from "./use_url_state.js";
|
|
44
44
|
export { urlParam } from "./url_params.js";
|
|
45
45
|
export type { UrlParamCodec, OptionalUrlParamCodec, UrlParams, UrlParamValue, } from "./url_params.js";
|
package/dist/src/index.js
CHANGED
|
@@ -19,7 +19,7 @@ export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOpt
|
|
|
19
19
|
export { useComments, useCommentCounts } from "./comments.js";
|
|
20
20
|
export { useViewer } from "./viewer.js";
|
|
21
21
|
export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
|
|
22
|
-
export { rpc } from "./rpc.js";
|
|
22
|
+
export { rpc, isEmbedded } from "./rpc.js";
|
|
23
23
|
export { openExternal } from "./open_external.js";
|
|
24
24
|
export { downloadFile } from "./download.js";
|
|
25
25
|
export { readMembers } from "./members.js";
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* In-app routing for custom-code apps — `AppRouter` lets an app use react-router
|
|
4
|
+
* normally (`useNavigate`, `useParams`, `<Link>`) while its screens become real,
|
|
5
|
+
* addressable URLs in both modes:
|
|
6
|
+
*
|
|
7
|
+
* - **Standalone** (`<slug>.lotics.app`): a normal browser history — real path
|
|
8
|
+
* URLs, native browser back/forward, deep-link/refresh via the app host's SPA
|
|
9
|
+
* fallback.
|
|
10
|
+
* - **Embedded** (inside the Lotics host): the app can't touch the cross-origin
|
|
11
|
+
* host URL, so a custom history proxies every push/replace/go to the host over
|
|
12
|
+
* the bridge. The host owns the single history stack, so the screen lives in
|
|
13
|
+
* its address bar (`?_loc=…`) — shareable + refresh-survivable — and the
|
|
14
|
+
* browser Back/Forward buttons walk app screens (then leave the app).
|
|
15
|
+
*
|
|
16
|
+
* Shipped as a separate entry (`@lotics/app-sdk/router`) so apps that don't route
|
|
17
|
+
* never pull react-router into their bundle:
|
|
18
|
+
*
|
|
19
|
+
* import { AppRouter } from "@lotics/app-sdk/router";
|
|
20
|
+
* export default function App() {
|
|
21
|
+
* return <AppRouter routes={[
|
|
22
|
+
* { path: "/", element: <List /> },
|
|
23
|
+
* { path: "/item/:id", element: <Detail /> },
|
|
24
|
+
* ]} />;
|
|
25
|
+
* }
|
|
26
|
+
*/
|
|
27
|
+
import { useState } from "react";
|
|
28
|
+
import { NavigationType, UNSAFE_createBrowserHistory, unstable_HistoryRouter as HistoryRouter, useRoutes, } from "react-router-dom";
|
|
29
|
+
import { getUrlParams, goUrlHistory, isEmbedded, setUrlParams, subscribeUrlParams } from "./rpc.js";
|
|
30
|
+
/** Host query key carrying the app's current screen when embedded, so it's
|
|
31
|
+
* shareable and survives refresh. */
|
|
32
|
+
const LOC_KEY = "_loc";
|
|
33
|
+
function hrefOf(to) {
|
|
34
|
+
if (typeof to === "string")
|
|
35
|
+
return to || "/";
|
|
36
|
+
return (to.pathname ?? "/") + (to.search ?? "") + (to.hash ?? "");
|
|
37
|
+
}
|
|
38
|
+
let locKey = 0;
|
|
39
|
+
function toLocation(href) {
|
|
40
|
+
const u = new URL(href || "/", "http://x");
|
|
41
|
+
return { pathname: u.pathname, search: u.search, hash: u.hash, state: null, key: String(++locKey) };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Embedded history: the host owns the single history stack.
|
|
45
|
+
* - `push`/`replace` optimistically update the local location and tell the host
|
|
46
|
+
* (pushState/replaceState don't fire popstate, so no echo back).
|
|
47
|
+
* - `go` (and the browser Back/Forward buttons) move the host history; the
|
|
48
|
+
* resulting location arrives via the `url-state` broadcast as an action POP.
|
|
49
|
+
*/
|
|
50
|
+
function createBridgedHistory() {
|
|
51
|
+
const listeners = new Set();
|
|
52
|
+
let location = toLocation("/");
|
|
53
|
+
let action = NavigationType.Pop;
|
|
54
|
+
// Once the location has been driven — by the app (push/replace) or the host
|
|
55
|
+
// (a back/forward broadcast) — a late boot-seed must not clobber it; the
|
|
56
|
+
// bridged read of the host URL resolves a tick after mount.
|
|
57
|
+
let seeded = false;
|
|
58
|
+
// `delta` is history metadata (scroll restoration / blockers), not used for
|
|
59
|
+
// rendering — best-effort per action, since the host doesn't report the exact
|
|
60
|
+
// index change on a broadcast.
|
|
61
|
+
function set(href, act, delta) {
|
|
62
|
+
seeded = true;
|
|
63
|
+
location = toLocation(href);
|
|
64
|
+
action = act;
|
|
65
|
+
for (const l of listeners)
|
|
66
|
+
l({ action, location, delta });
|
|
67
|
+
}
|
|
68
|
+
// Host back/forward (browser Back or our own `go`) → re-render to the broadcast
|
|
69
|
+
// location. Guarded so an already-current location doesn't re-fire.
|
|
70
|
+
subscribeUrlParams((params) => {
|
|
71
|
+
const raw = params[LOC_KEY];
|
|
72
|
+
const href = typeof raw === "string" ? raw : "/";
|
|
73
|
+
if (href !== hrefOf(location))
|
|
74
|
+
set(href, NavigationType.Pop, -1);
|
|
75
|
+
});
|
|
76
|
+
// Boot: seed from the host URL (resolves a tick after mount — first paint is
|
|
77
|
+
// "/"). Skip if the location was already driven, so a navigation that races the
|
|
78
|
+
// bridge read isn't clobbered.
|
|
79
|
+
void getUrlParams().then((params) => {
|
|
80
|
+
if (seeded)
|
|
81
|
+
return;
|
|
82
|
+
const raw = params[LOC_KEY];
|
|
83
|
+
if (typeof raw === "string" && raw !== hrefOf(location))
|
|
84
|
+
set(raw, NavigationType.Pop, 0);
|
|
85
|
+
});
|
|
86
|
+
return {
|
|
87
|
+
get action() {
|
|
88
|
+
return action;
|
|
89
|
+
},
|
|
90
|
+
get location() {
|
|
91
|
+
return location;
|
|
92
|
+
},
|
|
93
|
+
createHref: (to) => hrefOf(to),
|
|
94
|
+
createURL: (to) => new URL(hrefOf(to), window.location.origin),
|
|
95
|
+
encodeLocation: (to) => {
|
|
96
|
+
const u = new URL(hrefOf(to), "http://x");
|
|
97
|
+
return { pathname: u.pathname, search: u.search, hash: u.hash };
|
|
98
|
+
},
|
|
99
|
+
push: (to) => {
|
|
100
|
+
const href = hrefOf(to);
|
|
101
|
+
set(href, NavigationType.Push, 1);
|
|
102
|
+
void setUrlParams({ [LOC_KEY]: href }, true);
|
|
103
|
+
},
|
|
104
|
+
replace: (to) => {
|
|
105
|
+
const href = hrefOf(to);
|
|
106
|
+
set(href, NavigationType.Replace, 0);
|
|
107
|
+
void setUrlParams({ [LOC_KEY]: href }, false);
|
|
108
|
+
},
|
|
109
|
+
go: (delta) => {
|
|
110
|
+
void goUrlHistory(delta);
|
|
111
|
+
},
|
|
112
|
+
listen: (listener) => {
|
|
113
|
+
listeners.add(listener);
|
|
114
|
+
return () => listeners.delete(listener);
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function RoutedRoutes({ routes }) {
|
|
119
|
+
return useRoutes(routes);
|
|
120
|
+
}
|
|
121
|
+
export function AppRouter({ routes }) {
|
|
122
|
+
// Created once (a lazy state initializer survives StrictMode's double render,
|
|
123
|
+
// so the bridge subscription isn't installed twice).
|
|
124
|
+
const [history] = useState(() => isEmbedded() ? createBridgedHistory() : UNSAFE_createBrowserHistory());
|
|
125
|
+
return (_jsx(HistoryRouter, { history: history, children: _jsx(RoutedRoutes, { routes: routes }) }));
|
|
126
|
+
}
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
|
|
|
19
19
|
* app → host: { id, op, payload }
|
|
20
20
|
* host → app: { id, type: "result", data } | { id, type: "error", message }
|
|
21
21
|
*/
|
|
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
|
+
export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "urlState.get" | "urlState.set" | "urlState.go" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
|
|
23
23
|
/** Payload for starting a streaming agent run. */
|
|
24
24
|
export interface AgentRunPayload {
|
|
25
25
|
alias: string;
|
|
@@ -58,20 +58,31 @@ export interface AppContext {
|
|
|
58
58
|
*/
|
|
59
59
|
comments_enabled: boolean;
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Whether the app is running embedded in a Lotics host (vs. standalone at its
|
|
63
|
+
* own `<slug>.lotics.app`). The router adapter (`@lotics/app-sdk/router`) uses
|
|
64
|
+
* this to pick its history backend; an app rarely needs it directly.
|
|
65
|
+
*/
|
|
66
|
+
export declare function isEmbedded(): boolean;
|
|
61
67
|
export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>;
|
|
62
68
|
/** Read the current app-owned query params — bridged: ask the host; standalone:
|
|
63
69
|
* the page's own query string. */
|
|
64
70
|
export declare function getUrlParams(): Promise<UrlParams>;
|
|
65
|
-
/** Merge `patch` into the
|
|
66
|
-
* is `undefined`). `push` adds a
|
|
67
|
-
*
|
|
68
|
-
|
|
71
|
+
/** Merge `patch` into the host's address bar (each key set, or cleared when its
|
|
72
|
+
* value is `undefined`), preserving every other param. `push` adds a back-able
|
|
73
|
+
* history entry — the router adapter uses it per in-app navigation; the default
|
|
74
|
+
* replaces in place (filter changes shouldn't flood history). */
|
|
75
|
+
export declare function setUrlParams(patch: UrlParamsPatch, push?: boolean): Promise<void>;
|
|
76
|
+
/** Move the host's history by `delta` (the router adapter's back/forward). The
|
|
77
|
+
* resulting location arrives via the `url-state` broadcast, not the return. */
|
|
78
|
+
export declare function goUrlHistory(delta: number): Promise<void>;
|
|
69
79
|
/** Synchronous best-effort snapshot for first paint. Standalone reads its own
|
|
70
80
|
* URL (no flash); bridged can't read the cross-origin host URL synchronously,
|
|
71
81
|
* so it returns `{}` and the hook hydrates via `getUrlParams()` on mount. */
|
|
72
82
|
export declare function peekUrlParams(): UrlParams;
|
|
73
|
-
/** Subscribe to external query changes — back/forward
|
|
74
|
-
*
|
|
83
|
+
/** Subscribe to external query changes — back/forward (browser or the adapter's
|
|
84
|
+
* `go`) and edited URLs. Embedded: the host's `url-state` broadcast; standalone:
|
|
85
|
+
* `popstate`. */
|
|
75
86
|
export declare function subscribeUrlParams(cb: (params: UrlParams) => void): () => void;
|
|
76
87
|
/**
|
|
77
88
|
* Start a streaming agent run. Each raw SSE text chunk is handed to `onText`
|
package/dist/src/rpc.js
CHANGED
|
@@ -18,40 +18,60 @@ function getHostOrigin() {
|
|
|
18
18
|
}
|
|
19
19
|
return hostOriginCache;
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether the app is running embedded in a Lotics host (vs. standalone at its
|
|
23
|
+
* own `<slug>.lotics.app`). The router adapter (`@lotics/app-sdk/router`) uses
|
|
24
|
+
* this to pick its history backend; an app rarely needs it directly.
|
|
25
|
+
*/
|
|
26
|
+
export function isEmbedded() {
|
|
27
|
+
// Truthiness, not `!== null`: a present-but-empty `?lotics_host=` yields "",
|
|
28
|
+
// which is no usable host origin — so it's standalone, matching how `rpc()`
|
|
29
|
+
// branches transports below.
|
|
30
|
+
return Boolean(getHostOrigin());
|
|
31
|
+
}
|
|
21
32
|
export function rpc(op, payload) {
|
|
22
33
|
const hostOrigin = getHostOrigin();
|
|
23
34
|
return hostOrigin
|
|
24
35
|
? rpcBridged(op, payload, hostOrigin)
|
|
25
36
|
: rpcStandalone(op, payload);
|
|
26
37
|
}
|
|
27
|
-
// ── URL state (the address bar as
|
|
38
|
+
// ── URL state (the address bar as app view-state + route) ───────────────────
|
|
28
39
|
//
|
|
29
|
-
// `useUrlState`
|
|
30
|
-
//
|
|
31
|
-
// app can't
|
|
32
|
-
// flow over the bridge
|
|
40
|
+
// Two consumers share these ops: `useUrlState` (a declared, typed slice of
|
|
41
|
+
// filters/search) and the router adapter (`@lotics/app-sdk/router`, the app's
|
|
42
|
+
// current screen under `_loc`). An embedded app can't touch the cross-origin
|
|
43
|
+
// host URL directly, so reads/writes/back-forward flow over the bridge: the host
|
|
44
|
+
// drives its own history (`set` with `push`, `go` for back/forward) and pushes
|
|
45
|
+
// the resulting params back via the `url-state` broadcast. A standalone app owns
|
|
33
46
|
// its top-level URL and the same ops resolve against `window.location`.
|
|
34
47
|
/** Read the current app-owned query params — bridged: ask the host; standalone:
|
|
35
48
|
* the page's own query string. */
|
|
36
49
|
export function getUrlParams() {
|
|
37
50
|
return rpc("urlState.get", {});
|
|
38
51
|
}
|
|
39
|
-
/** Merge `patch` into the
|
|
40
|
-
* is `undefined`). `push` adds a
|
|
41
|
-
*
|
|
42
|
-
|
|
52
|
+
/** Merge `patch` into the host's address bar (each key set, or cleared when its
|
|
53
|
+
* value is `undefined`), preserving every other param. `push` adds a back-able
|
|
54
|
+
* history entry — the router adapter uses it per in-app navigation; the default
|
|
55
|
+
* replaces in place (filter changes shouldn't flood history). */
|
|
56
|
+
export function setUrlParams(patch, push = false) {
|
|
43
57
|
return rpc("urlState.set", { params: patch, push });
|
|
44
58
|
}
|
|
59
|
+
/** Move the host's history by `delta` (the router adapter's back/forward). The
|
|
60
|
+
* resulting location arrives via the `url-state` broadcast, not the return. */
|
|
61
|
+
export function goUrlHistory(delta) {
|
|
62
|
+
return rpc("urlState.go", { delta });
|
|
63
|
+
}
|
|
45
64
|
/** Synchronous best-effort snapshot for first paint. Standalone reads its own
|
|
46
65
|
* URL (no flash); bridged can't read the cross-origin host URL synchronously,
|
|
47
66
|
* so it returns `{}` and the hook hydrates via `getUrlParams()` on mount. */
|
|
48
67
|
export function peekUrlParams() {
|
|
49
|
-
return
|
|
68
|
+
return isEmbedded() ? {} : parseSearch(window.location.search);
|
|
50
69
|
}
|
|
51
|
-
/** Subscribe to external query changes — back/forward
|
|
52
|
-
*
|
|
70
|
+
/** Subscribe to external query changes — back/forward (browser or the adapter's
|
|
71
|
+
* `go`) and edited URLs. Embedded: the host's `url-state` broadcast; standalone:
|
|
72
|
+
* `popstate`. */
|
|
53
73
|
export function subscribeUrlParams(cb) {
|
|
54
|
-
if (
|
|
74
|
+
if (isEmbedded()) {
|
|
55
75
|
ensureListener();
|
|
56
76
|
urlStateSubscribers.add(cb);
|
|
57
77
|
return () => {
|
|
@@ -64,8 +84,8 @@ export function subscribeUrlParams(cb) {
|
|
|
64
84
|
}
|
|
65
85
|
const pending = new Map();
|
|
66
86
|
const streaming = new Map();
|
|
67
|
-
/** `useUrlState`
|
|
68
|
-
*
|
|
87
|
+
/** `useUrlState` + the router adapter — notified when the host broadcasts new
|
|
88
|
+
* params after browser/adapter back/forward. */
|
|
69
89
|
const urlStateSubscribers = new Set();
|
|
70
90
|
let nextRpcId = 0;
|
|
71
91
|
let listenerInstalled = false;
|
|
@@ -80,8 +100,8 @@ function ensureListener() {
|
|
|
80
100
|
const msg = event.data;
|
|
81
101
|
if (!msg)
|
|
82
102
|
return;
|
|
83
|
-
// Broadcast (no id): the host pushes
|
|
84
|
-
//
|
|
103
|
+
// Broadcast (no id): the host pushes new params after browser/adapter
|
|
104
|
+
// back/forward so subscribers (`useUrlState`, the router adapter) re-hydrate.
|
|
85
105
|
if (msg.type === "url-state" && msg.params && typeof msg.params === "object") {
|
|
86
106
|
for (const cb of urlStateSubscribers)
|
|
87
107
|
cb(msg.params);
|
|
@@ -384,6 +404,12 @@ function rpcStandalone(op, payload) {
|
|
|
384
404
|
return Promise.resolve(parseSearch(window.location.search));
|
|
385
405
|
case "urlState.set":
|
|
386
406
|
return standaloneUrlStateSet(payload);
|
|
407
|
+
case "urlState.go": {
|
|
408
|
+
// Standalone owns its own history — move it; popstate re-hydrates subscribers.
|
|
409
|
+
const { delta } = payload;
|
|
410
|
+
window.history.go(delta);
|
|
411
|
+
return Promise.resolve();
|
|
412
|
+
}
|
|
387
413
|
case "comments.list":
|
|
388
414
|
case "comments.create":
|
|
389
415
|
case "comments.update":
|
|
@@ -435,8 +461,8 @@ async function standaloneOpenExternal(p) {
|
|
|
435
461
|
async function standaloneUrlStateSet(p) {
|
|
436
462
|
const search = serializeMerge(window.location.search, p.params ?? {});
|
|
437
463
|
const url = window.location.pathname + (search ? `?${search}` : "") + window.location.hash;
|
|
438
|
-
// pushState/replaceState don't fire popstate, so
|
|
439
|
-
//
|
|
464
|
+
// pushState/replaceState don't fire popstate, so no echo — the caller updated
|
|
465
|
+
// optimistically on write.
|
|
440
466
|
if (p.push)
|
|
441
467
|
window.history.pushState(null, "", url);
|
|
442
468
|
else
|
|
@@ -5,12 +5,4 @@ export type UrlStateShape = Record<string, UrlParamCodec<unknown>>;
|
|
|
5
5
|
export type UrlStateValues<D extends UrlStateShape> = {
|
|
6
6
|
[K in keyof D]: D[K] extends UrlParamCodec<infer T> ? T : never;
|
|
7
7
|
};
|
|
8
|
-
export
|
|
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
|
-
];
|
|
8
|
+
export declare function useUrlState<D extends UrlStateShape>(defs: D): readonly [UrlStateValues<D>, (patch: Partial<UrlStateValues<D>>) => void];
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* active tab —
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Save a declared slice of an app's view-state — filters, search, sort, the
|
|
3
|
+
* active tab — into the host's address bar, so a filtered view survives refresh
|
|
4
|
+
* and is shareable/bookmarkable as a link. Writes always *replace* in place: the
|
|
5
|
+
* host owns no in-app history, and in-app navigation (and the browser back
|
|
6
|
+
* button) belong to the app's own router, not to this hook.
|
|
7
7
|
*
|
|
8
8
|
* const [filters, setFilters] = useUrlState({
|
|
9
9
|
* q: urlParam.string.withDefault(""),
|
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
* page: urlParam.number.withDefault(1),
|
|
13
13
|
* });
|
|
14
14
|
* // filters → { q: string; status?: "open"|"won"|"lost"; tags: string[]; page: number }
|
|
15
|
-
* setFilters({ q: "acme" }); // merge
|
|
16
|
-
* setFilters({ status: "won" }, { push: true }); // back-able navigation
|
|
15
|
+
* setFilters({ q: "acme" }); // merge into the address bar (replace)
|
|
17
16
|
*
|
|
18
17
|
* The app declares only the keys it owns; every other query param (a param a
|
|
19
18
|
* second `useUrlState` owns, the framework's `lotics_host`/`__mock`, a future
|
|
@@ -23,9 +22,9 @@
|
|
|
23
22
|
*
|
|
24
23
|
* The address bar is the only store: nothing is persisted server-side. Values
|
|
25
24
|
* are decoded fresh from the current params each render; standalone reads the
|
|
26
|
-
* URL directly, while an embedded app keeps a local mirror
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* URL directly, while an embedded app keeps a local mirror seeded from the host
|
|
26
|
+
* (`urlState.get` on mount) and updated optimistically on its own writes — so
|
|
27
|
+
* there's no independently-mutable second copy to drift.
|
|
29
28
|
*/
|
|
30
29
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
31
30
|
import { getUrlParams, peekUrlParams, setUrlParams, subscribeUrlParams } from "./rpc.js";
|
|
@@ -56,7 +55,7 @@ export function useUrlState(defs) {
|
|
|
56
55
|
};
|
|
57
56
|
}, []);
|
|
58
57
|
const values = useMemo(() => decodeAll(defsRef.current, params), [params]);
|
|
59
|
-
const setValues = useCallback((patch
|
|
58
|
+
const setValues = useCallback((patch) => {
|
|
60
59
|
editedRef.current = true;
|
|
61
60
|
const encoded = encodePatch(defsRef.current, patch);
|
|
62
61
|
// Optimistic local mirror so the UI is responsive even before the write
|
|
@@ -66,7 +65,7 @@ export function useUrlState(defs) {
|
|
|
66
65
|
// The optimistic mirror already reflects the change, so a failed write
|
|
67
66
|
// only loses cross-refresh persistence, never the session — keep the
|
|
68
67
|
// optimistic state, but surface the failure instead of swallowing it.
|
|
69
|
-
void setUrlParams(encoded
|
|
68
|
+
void setUrlParams(encoded).catch((err) => {
|
|
70
69
|
console.error("useUrlState: failed to write state to the address bar", err);
|
|
71
70
|
});
|
|
72
71
|
}, []);
|
package/package.json
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/app-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
|
-
".":
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/src/index.d.ts",
|
|
9
|
+
"default": "./dist/src/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./router": {
|
|
12
|
+
"types": "./dist/src/router.d.ts",
|
|
13
|
+
"default": "./dist/src/router.js"
|
|
14
|
+
}
|
|
8
15
|
},
|
|
9
16
|
"types": "./dist/src/index.d.ts",
|
|
10
17
|
"files": [
|
|
@@ -23,13 +30,20 @@
|
|
|
23
30
|
},
|
|
24
31
|
"peerDependencies": {
|
|
25
32
|
"react": "^19.2.0",
|
|
26
|
-
"react-dom": "^19.2.0"
|
|
33
|
+
"react-dom": "^19.2.0",
|
|
34
|
+
"react-router-dom": "^7.0.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependenciesMeta": {
|
|
37
|
+
"react-router-dom": {
|
|
38
|
+
"optional": true
|
|
39
|
+
}
|
|
27
40
|
},
|
|
28
41
|
"devDependencies": {
|
|
29
42
|
"@types/react": "^19.0.0",
|
|
30
43
|
"@types/react-dom": "^19.0.0",
|
|
31
44
|
"react": "^19.2.0",
|
|
32
|
-
"react-dom": "^19.2.0"
|
|
45
|
+
"react-dom": "^19.2.0",
|
|
46
|
+
"react-router-dom": "^7.0.0"
|
|
33
47
|
},
|
|
34
48
|
"keywords": [
|
|
35
49
|
"lotics",
|