@lotics/app-sdk 0.38.1 → 0.41.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 +46 -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 +79 -0
- package/dist/src/rpc.d.ts +12 -6
- package/dist/src/rpc.js +38 -25
- 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,12 @@ 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;
|
|
82
|
+
the app owns its own url (embedded, that's the iframe's own url — never the host's), so navigation never
|
|
83
|
+
reloads the app, browser back/forward walk app screens, and screens stay shareable + refresh-survivable.
|
|
84
|
+
See *In-app navigation*.
|
|
81
85
|
- **Optimistic mutation glue** — **`useOptimistic()`** → reconcile a workflow mutation against the
|
|
82
86
|
query cache for an interactive (calendar/kanban/grid) app. See the data-bound recipe.
|
|
83
87
|
- **Infra** — **`mount(opts?)`** boots the app + analytics (call once at entry; PostHog is automatic,
|
|
@@ -250,12 +254,13 @@ stops constraining instead of erroring (no-op for all-required queries).
|
|
|
250
254
|
independently. Keep a scope that must always apply `required` (a missing required param 400s). Typos
|
|
251
255
|
can't widen — deploy rejects a `{{params.x}}` with no declared param.
|
|
252
256
|
|
|
253
|
-
##
|
|
257
|
+
## Save view-state to the URL (`useUrlState`)
|
|
254
258
|
|
|
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
|
-
|
|
259
|
+
The filters above are the query's *server* params; **`useUrlState`** is their *client* home — save them to
|
|
260
|
+
the host's address bar and a filtered view survives refresh and is shareable/bookmarkable as a link. Writes
|
|
261
|
+
always **replace** in place (filter changes shouldn't add history entries); screen-to-screen navigation and
|
|
262
|
+
history are the router's job (next section). The hook drives the host's URL over the bridge; the app can't
|
|
263
|
+
touch the cross-origin host URL itself. Standalone apps drive their own URL — same code.
|
|
259
264
|
|
|
260
265
|
```tsx
|
|
261
266
|
const [filters, setFilters] = useUrlState({
|
|
@@ -266,8 +271,8 @@ const [filters, setFilters] = useUrlState({
|
|
|
266
271
|
});
|
|
267
272
|
// filters → { q: string; status?: "open"|"won"|"lost"; tags: string[]; page: number }
|
|
268
273
|
const { rows } = usePaginatedQuery("search", { keyword: filters.q, status: filters.status }, { pageSize: 50 });
|
|
269
|
-
setFilters({ status: "won" }); // merge
|
|
270
|
-
setFilters({ page: 2 }
|
|
274
|
+
setFilters({ status: "won" }); // merge into the address bar → ?status=won
|
|
275
|
+
setFilters({ page: 2 }); // merge (replace; no history entry)
|
|
271
276
|
```
|
|
272
277
|
|
|
273
278
|
- **`urlParam`** codecs: `string` / `number` / `boolean` / `isoDate` / `enum([...])` / `arrayOf(inner)`,
|
|
@@ -277,8 +282,38 @@ setFilters({ page: 2 }, { push: true }); // back-able navigation
|
|
|
277
282
|
- **Declared keys only.** The app touches just the keys in `shape`; every other param (a second
|
|
278
283
|
`useUrlState`, the framework's own) is preserved on write — no namespace rule to remember.
|
|
279
284
|
- **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
|
-
|
|
285
|
+
in an embedded app each `setFilters` is a cross-frame write.
|
|
286
|
+
|
|
287
|
+
## In-app navigation (`AppRouter`)
|
|
288
|
+
|
|
289
|
+
For a multi-screen app, write plain react-router and wrap your routes in **`AppRouter`** (from
|
|
290
|
+
`@lotics/app-sdk/router`) — it makes screens real, addressable URLs in both modes, with no per-mode code:
|
|
291
|
+
|
|
292
|
+
```tsx
|
|
293
|
+
import { AppRouter } from "@lotics/app-sdk/router";
|
|
294
|
+
|
|
295
|
+
export default function App() {
|
|
296
|
+
return <AppRouter routes={[
|
|
297
|
+
{ path: "/", element: <List /> },
|
|
298
|
+
{ path: "/item/:id", element: <Detail /> },
|
|
299
|
+
]} />;
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Inside, use react-router normally — `useNavigate`, `useParams`, `<Link>`.
|
|
304
|
+
|
|
305
|
+
- **Embedded** (in the Lotics host): the app drives the **iframe's own url** (same-origin to itself) via
|
|
306
|
+
`pushState`. The user sees the host's address bar, never the iframe's, so the screen url is invisible — and
|
|
307
|
+
the host never sees it, so navigation never reloads the app. The iframe's history participates in the
|
|
308
|
+
session history, so the **browser Back/Forward buttons walk app screens** (then leave the app). `AppRouter`
|
|
309
|
+
also mirrors the current screen into the host url (a non-remounting write) and the host bakes it back into
|
|
310
|
+
the iframe src on load, so a screen is **shareable and survives a full refresh** — no reload, no extra code.
|
|
311
|
+
- **Standalone** (`<slug>.lotics.app`): a normal browser router — real path URLs, native browser back/forward,
|
|
312
|
+
refresh-survivable.
|
|
313
|
+
|
|
314
|
+
`useUrlState` is the complement: in-app *routing* (screens) is automatic via `AppRouter`; reach for
|
|
315
|
+
`useUrlState` for *filters/search* you want as first-class, typed, shareable query keys. `react-router-dom` is
|
|
316
|
+
an optional peer — only apps that import `@lotics/app-sdk/router` pull it in.
|
|
282
317
|
|
|
283
318
|
---
|
|
284
319
|
|
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,79 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } 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. The app owns its OWN url (a plain browser history) in both
|
|
6
|
+
* modes; the host url only ever *mirrors* the screen, it never drives the router:
|
|
7
|
+
*
|
|
8
|
+
* - **Standalone** (`<slug>.lotics.app`): the page's own browser history — real
|
|
9
|
+
* path URLs, native browser back/forward, deep-link/refresh via the app host's
|
|
10
|
+
* SPA fallback.
|
|
11
|
+
* - **Embedded** (inside the Lotics host): the app drives the IFRAME's own url
|
|
12
|
+
* (the iframe is same-origin to itself) via `pushState`. The user sees the
|
|
13
|
+
* host's address bar, never the iframe's, and the host never sees the iframe's
|
|
14
|
+
* url — so it never navigates and never remounts the iframe. (Reflecting the
|
|
15
|
+
* screen into the host url with a *navigation* is what used to remount the app
|
|
16
|
+
* iframe and reload the whole app on every in-app navigation.) The iframe's
|
|
17
|
+
* history still participates in the session history, so browser Back/Forward
|
|
18
|
+
* walk app screens (then leave the app).
|
|
19
|
+
*
|
|
20
|
+
* To stay shareable + refresh-survivable, `AppRouter` *mirrors* the current
|
|
21
|
+
* screen into the host url under `_loc` via `setUrlParams` — a non-remounting
|
|
22
|
+
* `history.replaceState` on the host, never a navigation. The READ half is the
|
|
23
|
+
* host's job: on (re)load it bakes `_loc` into the iframe src, so the app boots
|
|
24
|
+
* *directly* at the saved screen. There's deliberately no async seed here —
|
|
25
|
+
* that would race the app's own first navigation; the iframe's initial url is
|
|
26
|
+
* the source of truth, set synchronously by the host.
|
|
27
|
+
*
|
|
28
|
+
* Shipped as a separate entry (`@lotics/app-sdk/router`) so apps that don't route
|
|
29
|
+
* never pull react-router into their bundle:
|
|
30
|
+
*
|
|
31
|
+
* import { AppRouter } from "@lotics/app-sdk/router";
|
|
32
|
+
* export default function App() {
|
|
33
|
+
* return <AppRouter routes={[
|
|
34
|
+
* { path: "/", element: <List /> },
|
|
35
|
+
* { path: "/item/:id", element: <Detail /> },
|
|
36
|
+
* ]} />;
|
|
37
|
+
* }
|
|
38
|
+
*/
|
|
39
|
+
import { useEffect } from "react";
|
|
40
|
+
import { BrowserRouter, useLocation, useRoutes, } from "react-router-dom";
|
|
41
|
+
import { isEmbedded, setUrlParams } from "./rpc.js";
|
|
42
|
+
/** Host query key carrying the app's current screen, so it's shareable and the
|
|
43
|
+
* host can restore it on refresh. */
|
|
44
|
+
const LOC_KEY = "_loc";
|
|
45
|
+
/** The host handshake param the host puts on the iframe src — present on the
|
|
46
|
+
* initial url only, never part of a route, so it's stripped from the mirror. */
|
|
47
|
+
const HOST_KEY = "lotics_host";
|
|
48
|
+
function screenHref(loc) {
|
|
49
|
+
const search = new URLSearchParams(loc.search);
|
|
50
|
+
search.delete(HOST_KEY);
|
|
51
|
+
const qs = search.toString();
|
|
52
|
+
return loc.pathname + (qs ? `?${qs}` : "") + loc.hash;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Embedded only: mirror the current screen into the host url under `_loc` — a
|
|
56
|
+
* non-remounting `setUrlParams` → `history.replaceState`, never a navigation, so
|
|
57
|
+
* it never reloads the app. Write-only by design: the host reads `_loc` back and
|
|
58
|
+
* bakes it into the iframe src on (re)load, so the app already boots at the saved
|
|
59
|
+
* screen — no async read here, hence no seed-vs-navigation race. Standalone needs
|
|
60
|
+
* none of this — the app's own url already IS the screen.
|
|
61
|
+
*/
|
|
62
|
+
function HostScreenMirror() {
|
|
63
|
+
const location = useLocation();
|
|
64
|
+
const href = screenHref(location);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
void setUrlParams({ [LOC_KEY]: href });
|
|
67
|
+
}, [href]);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
function RoutedRoutes({ routes }) {
|
|
71
|
+
return useRoutes(routes);
|
|
72
|
+
}
|
|
73
|
+
export function AppRouter({ routes }) {
|
|
74
|
+
// `isEmbedded()` reads the `?lotics_host=` the host puts on the iframe src, so
|
|
75
|
+
// it's known synchronously at first render.
|
|
76
|
+
const embedded = isEmbedded();
|
|
77
|
+
return (_jsxs(BrowserRouter, { children: [embedded ? _jsx(HostScreenMirror, {}) : null, _jsx(RoutedRoutes, { routes: routes })
|
|
78
|
+
] }));
|
|
79
|
+
}
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -58,20 +58,26 @@ 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`). `rpc()`, `useUrlState`, and `AppRouter` use this to
|
|
64
|
+
* pick the transport / behaviour; 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`)
|
|
67
|
-
*
|
|
68
|
-
export declare function setUrlParams(patch: UrlParamsPatch
|
|
71
|
+
/** Merge `patch` into the host's address bar (each key set, or cleared when its
|
|
72
|
+
* value is `undefined`), preserving every other param. The host writes in place
|
|
73
|
+
* (`history.replaceState`) — view-state changes don't add history entries. */
|
|
74
|
+
export declare function setUrlParams(patch: UrlParamsPatch): Promise<void>;
|
|
69
75
|
/** Synchronous best-effort snapshot for first paint. Standalone reads its own
|
|
70
76
|
* URL (no flash); bridged can't read the cross-origin host URL synchronously,
|
|
71
77
|
* so it returns `{}` and the hook hydrates via `getUrlParams()` on mount. */
|
|
72
78
|
export declare function peekUrlParams(): UrlParams;
|
|
73
|
-
/** Subscribe to external query changes — back/forward
|
|
74
|
-
*
|
|
79
|
+
/** Subscribe to external query changes — browser back/forward and edited URLs.
|
|
80
|
+
* Embedded: the host's `url-state` broadcast; standalone: `popstate`. */
|
|
75
81
|
export declare function subscribeUrlParams(cb: (params: UrlParams) => void): () => void;
|
|
76
82
|
/**
|
|
77
83
|
* Start a streaming agent run. Each raw SSE text chunk is handed to `onText`
|
package/dist/src/rpc.js
CHANGED
|
@@ -18,40 +18,56 @@ 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`). `rpc()`, `useUrlState`, and `AppRouter` use this to
|
|
24
|
+
* pick the transport / behaviour; 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 shareable view-state)
|
|
38
|
+
// ── URL state (the host address bar as shareable app view-state + screen) ────
|
|
28
39
|
//
|
|
29
|
-
// `useUrlState`
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
40
|
+
// Two consumers: `useUrlState` (a declared, typed slice of filters/search) and
|
|
41
|
+
// `AppRouter`'s screen mirror (the current screen under `_loc`). Both keep app
|
|
42
|
+
// state in the host's address bar so it survives refresh and is shareable. An
|
|
43
|
+
// embedded app can't touch the cross-origin host URL directly, so reads/writes
|
|
44
|
+
// flow over the bridge: the host writes the params in place (a non-remounting
|
|
45
|
+
// `history.replaceState` — never a navigation, which would reload the iframe) and
|
|
46
|
+
// pushes external changes (browser back/forward) back via the `url-state`
|
|
47
|
+
// broadcast. A standalone app owns its top-level URL and the same ops resolve
|
|
48
|
+
// against `window.location`. (In-app *routing* is not here — the app owns its own
|
|
49
|
+
// url via `@lotics/app-sdk/router`; see that module.)
|
|
34
50
|
/** Read the current app-owned query params — bridged: ask the host; standalone:
|
|
35
51
|
* the page's own query string. */
|
|
36
52
|
export function getUrlParams() {
|
|
37
53
|
return rpc("urlState.get", {});
|
|
38
54
|
}
|
|
39
|
-
/** Merge `patch` into the
|
|
40
|
-
* is `undefined`)
|
|
41
|
-
*
|
|
42
|
-
export function setUrlParams(patch
|
|
43
|
-
return rpc("urlState.set", { params: patch
|
|
55
|
+
/** Merge `patch` into the host's address bar (each key set, or cleared when its
|
|
56
|
+
* value is `undefined`), preserving every other param. The host writes in place
|
|
57
|
+
* (`history.replaceState`) — view-state changes don't add history entries. */
|
|
58
|
+
export function setUrlParams(patch) {
|
|
59
|
+
return rpc("urlState.set", { params: patch });
|
|
44
60
|
}
|
|
45
61
|
/** Synchronous best-effort snapshot for first paint. Standalone reads its own
|
|
46
62
|
* URL (no flash); bridged can't read the cross-origin host URL synchronously,
|
|
47
63
|
* so it returns `{}` and the hook hydrates via `getUrlParams()` on mount. */
|
|
48
64
|
export function peekUrlParams() {
|
|
49
|
-
return
|
|
65
|
+
return isEmbedded() ? {} : parseSearch(window.location.search);
|
|
50
66
|
}
|
|
51
|
-
/** Subscribe to external query changes — back/forward
|
|
52
|
-
*
|
|
67
|
+
/** Subscribe to external query changes — browser back/forward and edited URLs.
|
|
68
|
+
* Embedded: the host's `url-state` broadcast; standalone: `popstate`. */
|
|
53
69
|
export function subscribeUrlParams(cb) {
|
|
54
|
-
if (
|
|
70
|
+
if (isEmbedded()) {
|
|
55
71
|
ensureListener();
|
|
56
72
|
urlStateSubscribers.add(cb);
|
|
57
73
|
return () => {
|
|
@@ -64,8 +80,8 @@ export function subscribeUrlParams(cb) {
|
|
|
64
80
|
}
|
|
65
81
|
const pending = new Map();
|
|
66
82
|
const streaming = new Map();
|
|
67
|
-
/** `useUrlState` subscribers
|
|
68
|
-
*
|
|
83
|
+
/** `useUrlState` subscribers — notified when the host broadcasts new params
|
|
84
|
+
* after browser back/forward. */
|
|
69
85
|
const urlStateSubscribers = new Set();
|
|
70
86
|
let nextRpcId = 0;
|
|
71
87
|
let listenerInstalled = false;
|
|
@@ -80,8 +96,8 @@ function ensureListener() {
|
|
|
80
96
|
const msg = event.data;
|
|
81
97
|
if (!msg)
|
|
82
98
|
return;
|
|
83
|
-
// Broadcast (no id): the host pushes
|
|
84
|
-
//
|
|
99
|
+
// Broadcast (no id): the host pushes new params after browser back/forward
|
|
100
|
+
// so `useUrlState` subscribers re-hydrate.
|
|
85
101
|
if (msg.type === "url-state" && msg.params && typeof msg.params === "object") {
|
|
86
102
|
for (const cb of urlStateSubscribers)
|
|
87
103
|
cb(msg.params);
|
|
@@ -435,12 +451,9 @@ async function standaloneOpenExternal(p) {
|
|
|
435
451
|
async function standaloneUrlStateSet(p) {
|
|
436
452
|
const search = serializeMerge(window.location.search, p.params ?? {});
|
|
437
453
|
const url = window.location.pathname + (search ? `?${search}` : "") + window.location.hash;
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
-
window.history.pushState(null, "", url);
|
|
442
|
-
else
|
|
443
|
-
window.history.replaceState(null, "", url);
|
|
454
|
+
// View-state writes never add a history entry; replaceState doesn't fire
|
|
455
|
+
// popstate, so there's no echo — the caller already updated optimistically.
|
|
456
|
+
window.history.replaceState(null, "", url);
|
|
444
457
|
}
|
|
445
458
|
async function standaloneContext() {
|
|
446
459
|
const info = await resolveAppInfo();
|
|
@@ -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.41.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",
|