@declarion/react 0.27.0 → 0.28.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.
@@ -0,0 +1,27 @@
1
+ /** The minimal location shape canonicalUrl needs (a router `Location` satisfies
2
+ * it; tests can pass a bare object). */
3
+ export interface CanonicalLocation {
4
+ pathname: string;
5
+ search?: string;
6
+ }
7
+ /**
8
+ * Build the canonical URL key for a location: normalized pathname + the
9
+ * view-defining query, sorted by key, with the embed transport params removed.
10
+ *
11
+ * Sorting makes `?a=1&b=2` and `?b=2&a=1` one key. Removing `EMBED_PARAM_KEYS`
12
+ * makes the same screen one key whether or not the embed params ride along, so
13
+ * a retained page is shared between the embedded and non-embedded view of the
14
+ * same URL. Default query values are NOT collapsed (which params have a default
15
+ * is screen-specific and collapsing the wrong one would silently merge or fork
16
+ * pages); two URLs that differ only by an explicit-vs-implicit default are
17
+ * treated as distinct, which is safe (at worst slightly less sharing).
18
+ */
19
+ export declare function canonicalUrl(location: CanonicalLocation): string;
20
+ /**
21
+ * The SCREEN-scoped key: the normalized pathname alone, ignoring the query.
22
+ * Coarser than `canonicalUrl` - it does NOT fork when filters/sort/columns
23
+ * change. Used for deep state that must persist ACROSS in-screen query changes
24
+ * (row selection, pruned to the visible result set) rather than resetting per
25
+ * filtered view. Two filter views of the same screen share one screen key.
26
+ */
27
+ export declare function canonicalPath(location: CanonicalLocation): string;
@@ -0,0 +1,90 @@
1
+ /** The default deep-state bag key under which a screen's scroll anchor is
2
+ * stored. A screen with one scroll container uses this; a screen with several
3
+ * passes distinct keys. */
4
+ export declare const SCROLL_STATE_KEY = "scroll";
5
+ /**
6
+ * A saved scroll position. `key` is the stable key of the row at the top of the
7
+ * viewport (empty when the container had no rows / is non-virtualized); `delta`
8
+ * is the sub-row pixel offset; `offset` is the raw scroll offset kept as a
9
+ * last-resort fallback when the anchor row is gone.
10
+ */
11
+ export interface ScrollAnchor {
12
+ key: string;
13
+ delta: number;
14
+ offset: number;
15
+ }
16
+ /** The minimal virtualizer surface the seam needs. A
17
+ * `Virtualizer<HTMLElement, Element>` is structurally assignable; a test can
18
+ * pass a fake. */
19
+ export interface ScrollableVirtualizer {
20
+ scrollOffset: number | null;
21
+ getVirtualItemForOffset: (offset: number) => {
22
+ index: number;
23
+ start: number;
24
+ } | undefined;
25
+ getOffsetForIndex: (index: number, align?: "start") => readonly [number, unknown] | undefined;
26
+ getTotalSize: () => number;
27
+ scrollToOffset: (offset: number, opts?: {
28
+ align?: "start";
29
+ behavior?: "auto";
30
+ }) => void;
31
+ }
32
+ /**
33
+ * Capture the scroll anchor at `scrollOffset`. `topItem` is the virtual item at
34
+ * the top of the viewport (`getVirtualItemForOffset(scrollOffset)`); `rowKeys`
35
+ * maps its index to a stable row key. Returns an anchor with an empty `key`
36
+ * (raw-offset-only) when there is no top item or no key for it. Pure.
37
+ */
38
+ export declare function captureScrollAnchor(scrollOffset: number, topItem: {
39
+ index: number;
40
+ start: number;
41
+ } | undefined, rowKeys: string[]): ScrollAnchor;
42
+ /**
43
+ * Resolve the scroll offset to restore to, from a saved `anchor`, the CURRENT
44
+ * `rowKeys`, an `offsetForIndex` accessor (the virtualizer's measured start of a
45
+ * row), and the current `totalSize` (for clamping). Pure and testable.
46
+ *
47
+ * Fallback chain (Decision #17): anchor row found -> its measured start + delta;
48
+ * anchor row gone (deleted / filtered out) -> clamped raw offset; unusable
49
+ * offset -> top (0). Returns null when there is nothing saved.
50
+ */
51
+ export declare function resolveScrollOffset(anchor: ScrollAnchor | undefined, rowKeys: string[], offsetForIndex: (index: number) => number | undefined, totalSize: number): number | null;
52
+ /** Read the saved raw scroll offset for the current page ONCE, for seeding a
53
+ * virtualizer's `initialOffset` (read-once at init). Returns 0 when none /
54
+ * retention off. The full anchor re-restore happens after rows measure. */
55
+ export declare function useSavedScrollOffset(stateKey?: string): number;
56
+ /**
57
+ * Bind scroll save + anchor-row restore for a VIRTUALIZED list to the retained
58
+ * page keyed by the live canonical URL.
59
+ *
60
+ * - Restore: once per pageKey, after `rowKeys` first become non-empty, scrolls
61
+ * to `resolveScrollOffset(...)` (anchor row -> clamped raw -> top).
62
+ * - Save: a throttled scroll listener captures the top-of-viewport anchor and
63
+ * writes it imperatively (no re-render); the final position flushes on
64
+ * unmount / pageKey change to the DEPARTING view's own URL (tracked via a
65
+ * per-render ref).
66
+ *
67
+ * `enabled` (default true) lets a caller gate the seam; when false it is inert.
68
+ */
69
+ export declare function useVirtualScrollRestoration(opts: {
70
+ virtualizer: ScrollableVirtualizer | null;
71
+ scrollElementRef: {
72
+ current: HTMLElement | null;
73
+ };
74
+ rowKeys: string[];
75
+ stateKey?: string;
76
+ enabled?: boolean;
77
+ }): void;
78
+ /**
79
+ * Scroll save + restore for a PLAIN (non-virtualized) scroll container - the
80
+ * detail page. Stores the raw `scrollTop` (no anchor rows to key by) under the
81
+ * live canonical URL; restores after first layout; saves throttled-on-scroll +
82
+ * on unmount flush.
83
+ */
84
+ export declare function usePlainScrollRestoration(opts: {
85
+ scrollElementRef: {
86
+ current: HTMLElement | null;
87
+ };
88
+ stateKey?: string;
89
+ enabled?: boolean;
90
+ }): void;
@@ -0,0 +1,61 @@
1
+ import type { QueryClient, QueryKey } from "@tanstack/react-query";
2
+ /** Default retention window before the `ui_page_state_ttl_ms` parameter
3
+ * resolves (and the fallback when it is absent). 30 minutes - long enough to
4
+ * switch among tabs all day instantly, bounded enough to keep memory sane.
5
+ * Mirrors the YAML default in core_parameters.yaml; both must agree. */
6
+ export declare const DEFAULT_PAGE_STATE_TTL_MS: number;
7
+ /** Hard upper bound on retained-page entries. Bounds total memory and is the
8
+ * ONLY bound in embed mode, which has no tab-close signal at all. Generous
9
+ * relative to `ui_tabs_max_open` (a user rarely revisits more than this many
10
+ * distinct URLs within one TTL window); the least-recently-active entry is
11
+ * evicted first when the cap is exceeded. */
12
+ export declare const PAGE_STATE_MAX_ENTRIES = 64;
13
+ /** One retained page's deep state. `bag` holds small JSON-serializable deep
14
+ * values; `queryKeys` references the screen's PRIMARY React Query reads so an
15
+ * eviction can release the cached data alongside the deep state. */
16
+ export interface PageStateEntry {
17
+ bag: Record<string, unknown>;
18
+ lastActiveAt: number;
19
+ queryKeys: QueryKey[];
20
+ }
21
+ interface PageStateStore {
22
+ /** pageKey (canonical URL) -> retained-page deep state. */
23
+ entries: Record<string, PageStateEntry>;
24
+ /** Active retention window. `<= 0` disables retention. Set by the runtime
25
+ * from the resolved `ui_page_state_ttl_ms`; defaults to the 30-min fallback
26
+ * so retention works before the parameter loads. */
27
+ ttlMs: number;
28
+ /** Set the retention window. `<= 0` disables retention and immediately clears
29
+ * every entry (with linked `removeQueries`), so a runtime toggle to "off"
30
+ * frees memory at once. */
31
+ setTtl: (ttlMs: number) => void;
32
+ /** Write a deep value under `pageKey`, refreshing `lastActiveAt` and
33
+ * enforcing the LRU cap. No-op when retention is disabled. */
34
+ set: (pageKey: string, key: string, value: unknown) => void;
35
+ /** Read a deep value, or `undefined` when absent / reaped / retention off. */
36
+ read: (pageKey: string, key: string) => unknown;
37
+ /** Refresh `lastActiveAt` for a still-mounted view so it is not reaped while
38
+ * active. No-op when the entry is absent or retention is disabled. */
39
+ touch: (pageKey: string) => void;
40
+ /** Track one of the screen's PRIMARY React Query keys so eviction releases
41
+ * the cached data. Deduplicated. No-op when retention is disabled. */
42
+ registerQueryKey: (pageKey: string, queryKey: QueryKey) => void;
43
+ /** Explicitly evict one retained page NOW: drop its deep state AND
44
+ * `removeQueries` its tracked keys. Idempotent; works even when retention is
45
+ * disabled (used by per-screen kill policies, e.g. a deleted detail record). */
46
+ kill: (pageKey: string) => void;
47
+ /** Reap every entry stale past the active TTL (`now - lastActiveAt > ttlMs`),
48
+ * releasing data + deep state together. When retention is disabled, evicts
49
+ * all. `now` is injected for testability. */
50
+ sweep: (now: number) => void;
51
+ /** Drop EVERY retained page NOW (releasing all tracked queries) without
52
+ * changing the TTL - the auth-scope reset. Called on logout so a different
53
+ * user signing in within the same JS session cannot restore the prior user's
54
+ * deep state (selection, scroll, custom-screen values). */
55
+ clear: () => void;
56
+ }
57
+ /** Register the app's QueryClient so retained-page eviction can release the
58
+ * tracked entity-read queries. Called once at app mount. */
59
+ export declare function setPageStateQueryClient(client: QueryClient | null): void;
60
+ export declare const usePageStateStore: import("zustand").UseBoundStore<import("zustand").StoreApi<PageStateStore>>;
61
+ export {};
@@ -1,4 +1,4 @@
1
- import { J as e, V as t, X as n, Y as r, ft as i, t as a } from "./ActionRunner-D9fJRkNV.js";
1
+ import { H as e, X as t, Y as n, Z as r, mt as i, t as a } from "./ActionRunner-DtUDeH4F.js";
2
2
  import { useState as o } from "react";
3
3
  import { MemoryRouter as s } from "react-router-dom";
4
4
  import { QueryClient as c, QueryClientProvider as l } from "@tanstack/react-query";
@@ -87,7 +87,7 @@ function x(e) {
87
87
  }
88
88
  function S(i) {
89
89
  let a = i.entityData ?? {};
90
- n((e, t) => {
90
+ r((e, t) => {
91
91
  let n = a[e], r = x(n), i = r.meta?.per_page ?? 50, o = r.meta?.total ?? r.rows.length;
92
92
  return t.count === "only" ? {
93
93
  data: [],
@@ -109,19 +109,19 @@ function S(i) {
109
109
  };
110
110
  });
111
111
  let o = i.entityItems ?? {};
112
- r((e, n) => {
113
- let r = typeof n == "string" ? n : t(n);
114
- return { data: o[e]?.[r] };
112
+ t((t, n) => {
113
+ let r = typeof n == "string" ? n : e(n);
114
+ return { data: o[t]?.[r] };
115
115
  });
116
116
  let s = i.actionResults ?? {};
117
- e(async (e, t) => {
117
+ n(async (e, t) => {
118
118
  if (!(e in s)) throw Error(`renderWithDeclarion: no action result registered for "${e}" (seed it via actionResults)`);
119
119
  let n = s[e];
120
120
  return y(typeof n == "function" ? await n(t) : n);
121
121
  });
122
122
  }
123
123
  function C() {
124
- n(null), r(null), e(null), i.setState({
124
+ r(null), t(null), n(null), i.setState({
125
125
  user: null,
126
126
  isAuthenticated: !1,
127
127
  activeTenant: null,
@@ -1,4 +1,4 @@
1
- import { Lt as e, Nt as t, Pt as n } from "./ActionRunner-D9fJRkNV.js";
1
+ import { Ft as e, It as t, zt as n } from "./ActionRunner-DtUDeH4F.js";
2
2
  import { useCallback as r, useEffect as i, useRef as a, useState as o } from "react";
3
3
  //#region src/components/file-widgets/helpers.ts
4
4
  var s = ["thumb_200", "thumb_300"];
@@ -113,17 +113,17 @@ function T(e, t) {
113
113
  function E(e) {
114
114
  return `${e}${e.includes("?") ? "&" : "?"}disposition=attachment`;
115
115
  }
116
- async function D(e, r) {
117
- let i = await t(), a = await fetch(e, T(i, r));
116
+ async function D(n, r) {
117
+ let i = await e(), a = await fetch(n, T(i, r));
118
118
  if (a.status !== 401) return a;
119
- n();
120
- let o = await t();
121
- return fetch(e, T(o, r));
122
- }
123
- async function O(t, n, r) {
124
- if (!e()) throw Error("resolveEmbedFileSrc called outside embed token mode");
125
- let i = await D(C(t, n), r);
126
- if (i.status === 202 && typeof n == "object") throw new w(t, n.derivation);
119
+ t();
120
+ let o = await e();
121
+ return fetch(n, T(o, r));
122
+ }
123
+ async function O(e, t, r) {
124
+ if (!n()) throw Error("resolveEmbedFileSrc called outside embed token mode");
125
+ let i = await D(C(e, t), r);
126
+ if (i.status === 202 && typeof t == "object") throw new w(e, t.derivation);
127
127
  if (!i.ok) throw Error(`embed file url request failed: ${i.status} ${i.statusText}`);
128
128
  let a = await i.json();
129
129
  if (!S(a)) throw Error("embed file url response is malformed");
@@ -132,7 +132,7 @@ async function O(t, n, r) {
132
132
  kind: "direct"
133
133
  };
134
134
  if (!a.url.startsWith("/api/files/")) throw Error(`embed file proxy: refusing non-files-route URL ${JSON.stringify(a.url)}`);
135
- let o = await D(n === "download" ? E(a.url) : a.url, r);
135
+ let o = await D(t === "download" ? E(a.url) : a.url, r);
136
136
  if (!o.ok) throw Error(`embed file proxy fetch failed: ${o.status} ${o.statusText}`);
137
137
  let s = await o.blob();
138
138
  return {
@@ -148,23 +148,23 @@ function k(e) {
148
148
  function A(e) {
149
149
  return e.startsWith("derivation:") ? { derivation: e.slice(11) } : e === "download" ? "download" : "preview";
150
150
  }
151
- function j(t, n, r) {
152
- let a = k(n), [s, c] = o(() => e() ? void 0 : r);
151
+ function j(e, t, r) {
152
+ let a = k(t), [s, c] = o(() => n() ? void 0 : r);
153
153
  return i(() => {
154
- e() || c(r);
154
+ n() || c(r);
155
155
  }, [r]), i(() => {
156
- if (!e()) return;
157
- if (!t) {
156
+ if (!n()) return;
157
+ if (!e) {
158
158
  c(void 0);
159
159
  return;
160
160
  }
161
- let n = null, r = !1, i = new AbortController();
162
- return c(void 0), O(t, A(a), i.signal).then((e) => {
161
+ let t = null, r = !1, i = new AbortController();
162
+ return c(void 0), O(e, A(a), i.signal).then((e) => {
163
163
  if (r) {
164
164
  e.kind === "blob" && URL.revokeObjectURL(e.url);
165
165
  return;
166
166
  }
167
- e.kind === "blob" && (n = e.url), c(e.url);
167
+ e.kind === "blob" && (t = e.url), c(e.url);
168
168
  }).catch((e) => {
169
169
  if (!r) {
170
170
  if (e instanceof w) {
@@ -174,22 +174,22 @@ function j(t, n, r) {
174
174
  e instanceof DOMException && e.name === "AbortError" || (console.error("[declarion-embed] file src resolution failed:", e), c(void 0));
175
175
  }
176
176
  }), () => {
177
- r = !0, i.abort(), n && URL.revokeObjectURL(n);
177
+ r = !0, i.abort(), t && URL.revokeObjectURL(t);
178
178
  };
179
- }, [t, a]), s;
179
+ }, [e, a]), s;
180
180
  }
181
181
  function M() {
182
- let t = a([]);
182
+ let e = a([]);
183
183
  return i(() => () => {
184
- for (let e of t.current) URL.revokeObjectURL(e);
185
- t.current = [];
186
- }, []), { open: r((n, r, i) => {
187
- if (!e()) {
184
+ for (let t of e.current) URL.revokeObjectURL(t);
185
+ e.current = [];
186
+ }, []), { open: r((t, r, i) => {
187
+ if (!n()) {
188
188
  i && window.open(i, "_blank", "noopener");
189
189
  return;
190
190
  }
191
- n && O(n, r).then((e) => {
192
- e.kind === "blob" && t.current.push(e.url), window.open(e.url, "_blank", "noopener");
191
+ t && O(t, r).then((t) => {
192
+ t.kind === "blob" && e.current.push(t.url), window.open(t.url, "_blank", "noopener");
193
193
  }).catch((e) => {
194
194
  e instanceof w || console.error("[declarion-embed] file open failed:", e);
195
195
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@declarion/react",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "React SDK for Declarion, the schema-driven business apps platform.",