@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 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; `setValues(patch, { push })` makes it
80
- back/forward-navigable. Build `shape` from **`urlParam`** codecs. See *Shareable filters in the URL*.
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
- ## Shareable filters in the URL (`useUrlState`)
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 — 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
+ 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, replace historyURL ?status=won
270
- setFilters({ page: 2 }, { push: true }); // back-able navigation
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. Default is *replace* (no history entry);
281
- reserve `{ push: true }` for committed navigations (open a detail, switch a major tab).
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
 
@@ -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, SetUrlStateOptions } from "./use_url_state.js";
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,4 @@
1
+ import { type RouteObject } from "react-router-dom";
2
+ export declare function AppRouter({ routes }: {
3
+ routes: RouteObject[];
4
+ }): import("react/jsx-runtime").JSX.Element;
@@ -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 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>;
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, or an edited URL.
74
- * Bridged: the host's `url-state` broadcast; standalone: `popstate`. */
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` 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`.
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 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 });
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 getHostOrigin() ? {} : parseSearch(window.location.search);
65
+ return isEmbedded() ? {} : parseSearch(window.location.search);
50
66
  }
51
- /** Subscribe to external query changes — back/forward, or an edited URL.
52
- * Bridged: the host's `url-state` broadcast; standalone: `popstate`. */
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 (getHostOrigin()) {
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 (bridged transport) — notified when the host pushes
68
- * a `url-state` broadcast after an external navigation. */
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 the new query params after an external
84
- // navigation (back/forward, an edited URL) so `useUrlState` re-hydrates.
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
- // 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);
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 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
- ];
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
- * 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.
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, replace history
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 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.
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, options) => {
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, options?.push ?? false).catch((err) => {
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.38.1",
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
- ".": "./dist/src/index.js"
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",