@protobase/ui 0.40.5 → 0.40.7

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.
Files changed (33) hide show
  1. package/dist/app/app.js +7 -3
  2. package/dist/app/comments/comment-composer.js +2 -0
  3. package/dist/app/create/create-page.js +10 -2
  4. package/dist/app/messages.js +12 -0
  5. package/dist/app/new-version/messages.js +20 -0
  6. package/dist/app/new-version/new-version-button.js +27 -0
  7. package/dist/app/new-version/new-version-toast.js +43 -0
  8. package/dist/app/new-version/use-modal-open.js +19 -0
  9. package/dist/app/pages/blocks/modal-form.js +4 -0
  10. package/dist/app/record-page.js +1 -1
  11. package/dist/app/router.js +68 -26
  12. package/dist/data/api-provider.js +1 -0
  13. package/dist/data/meta-data.d.ts +9 -0
  14. package/dist/data/use-meta.d.ts +3 -2
  15. package/dist/data/use-meta.js +8 -4
  16. package/dist/data/version-freeze.js +17 -0
  17. package/dist/package.js +1 -1
  18. package/dist/shell/assets/app-BpvlppLd.js +9 -0
  19. package/dist/shell/assets/{index-DV6gtaxM.js → index-B37wY6Go.js} +2 -2
  20. package/dist/shell/assets/{records-map-rk8Ttge7.js → records-map-DD0ZBUZe.js} +2 -2
  21. package/dist/shell/assets/{shared-protobase-ui-BK6k1F1V.js → shared-protobase-ui-pUw1X1dc.js} +2 -2
  22. package/dist/shell/index.html +3 -3
  23. package/dist/shell/protobase.shell.json +1 -1
  24. package/dist/shell/protobase.sw.js +2 -2
  25. package/dist/shell-source-maps/assets/app-BpvlppLd.js.map +1 -0
  26. package/dist/shell-source-maps/assets/{index-DV6gtaxM.js.map → index-B37wY6Go.js.map} +1 -1
  27. package/dist/shell-source-maps/assets/{records-map-rk8Ttge7.js.map → records-map-DD0ZBUZe.js.map} +1 -1
  28. package/dist/shell-source-maps/assets/{shared-protobase-ui-BK6k1F1V.js.map → shared-protobase-ui-pUw1X1dc.js.map} +1 -1
  29. package/dist/toasts/toast-provider.js +11 -2
  30. package/dist/toasts/toast.js +3 -2
  31. package/package.json +4 -4
  32. package/dist/shell/assets/app-DSJ36HXd.js +0 -9
  33. package/dist/shell-source-maps/assets/app-DSJ36HXd.js.map +0 -1
package/dist/app/app.js CHANGED
@@ -52,6 +52,8 @@ import { PluginNotice } from "../plugins/plugin-notice.js";
52
52
  import { RecordPage } from "./record-page.js";
53
53
  import { SourceNotice } from "./source-notice.js";
54
54
  import { useGlobalSearch } from "./search/use-global-search.js";
55
+ import { NewVersionButton } from "./new-version/new-version-button.js";
56
+ import { NewVersionToast } from "./new-version/new-version-toast.js";
55
57
  import { RuntimeUpdateButton } from "./runtime/runtime-update.js";
56
58
  import { StaffBanner } from "./staff-banner.js";
57
59
  import { useNavRecent } from "./use-nav-recent.js";
@@ -140,6 +142,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
140
142
  url: runtimeUrl,
141
143
  client: runtime
142
144
  });
145
+ const reloadButton = meta.versionChange && /* @__PURE__ */ jsx(NewVersionButton, {});
143
146
  const role = useActiveRole(useCallback(() => reopenIn(basePath), [basePath]), Boolean(user));
144
147
  const acting = user && actingUser(user, role.current);
145
148
  const admin = acting ? isAdmin(acting) : false;
@@ -250,12 +253,13 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
250
253
  groups: search.groups,
251
254
  onSelect: open
252
255
  } : void 0,
253
- actions: (Actions || runtimeButton || assistantButton) && /* @__PURE__ */ jsxs(Fragment$1, { children: [
256
+ actions: (Actions || reloadButton || runtimeButton || assistantButton) && /* @__PURE__ */ jsxs(Fragment$1, { children: [
254
257
  Actions && /* @__PURE__ */ jsx(CustomBoundary, {
255
258
  name: "shell.actions",
256
259
  fallback: null,
257
260
  children: /* @__PURE__ */ jsx(Actions, {})
258
261
  }),
262
+ reloadButton,
259
263
  runtimeButton,
260
264
  assistantButton
261
265
  ] }),
@@ -291,7 +295,7 @@ const Authenticated = ({ baseUrl, client, sources, workspace, sidebarMode, ui, a
291
295
  sources,
292
296
  token: state.kind === "visitor" ? void 0 : session.token,
293
297
  onUnauthenticated: markSignedOut,
294
- children: /* @__PURE__ */ jsx(MetaGate, { children: /* @__PURE__ */ jsx(ToastProvider, { children: /* @__PURE__ */ jsx(PluginGate, {
298
+ children: /* @__PURE__ */ jsx(MetaGate, { children: /* @__PURE__ */ jsxs(ToastProvider, { children: [/* @__PURE__ */ jsx(NewVersionToast, {}), /* @__PURE__ */ jsx(PluginGate, {
295
299
  ui,
296
300
  children: /* @__PURE__ */ jsx(Routes, {
297
301
  workspace,
@@ -299,7 +303,7 @@ const Authenticated = ({ baseUrl, client, sources, workspace, sidebarMode, ui, a
299
303
  assistant,
300
304
  runtime
301
305
  })
302
- }) }) })
306
+ })] }) })
303
307
  });
304
308
  };
305
309
  const PublicFormGate = ({ baseUrl, client, ui, children }) => {
@@ -1,5 +1,6 @@
1
1
  import { cn } from "../../lib/cn.js";
2
2
  import { Button } from "../../primitives/button.js";
3
+ import { useNavigationGuard } from "../router.js";
3
4
  import { useToast } from "../../toasts/toast-provider.js";
4
5
  import { refusalReason } from "../displays/board-columns.js";
5
6
  import { Textarea } from "../../primitives/textarea.js";
@@ -29,6 +30,7 @@ const CommentComposer = ({ resource, recordKey }) => {
29
30
  const listId = useId();
30
31
  const toast = useToast();
31
32
  const post = usePostComment(resource, recordKey);
33
+ useNavigationGuard(draft.trim() !== "" || post.isPending, "input-lost");
32
34
  const mention = activeMention(draft, caret);
33
35
  const open = focused && mention !== void 0 && mention.start !== closed;
34
36
  const people = useMentionable(resource, recordKey, open ? mention.query : void 0);
@@ -1,4 +1,5 @@
1
1
  import { text } from "../messages.js";
2
+ import { useNavigationGuard } from "../router.js";
2
3
  import { useClient } from "../../data/api-provider.js";
3
4
  import { Notice, useAdminMeta, usePermissions } from "../meta-gate.js";
4
5
  import { humanize } from "../../live/naming.js";
@@ -50,6 +51,12 @@ const LoadedCreate = ({ resource, values }) => {
50
51
  const [errors, setErrors] = useState({});
51
52
  const [rejections, setRejections] = useState(0);
52
53
  const formRef = useFocusFirstInvalid(rejections);
54
+ const [newRecord, setNewRecord] = useState();
55
+ const [touched, setTouched] = useState(false);
56
+ useNavigationGuard(!newRecord && (touched || feedback.phase === "saving"), "input-lost");
57
+ useEffect(() => {
58
+ if (newRecord) go(newRecord, { replace: true });
59
+ }, [newRecord, go]);
53
60
  useEffect(() => {
54
61
  for (const [name, key] of Object.entries(values ?? {})) {
55
62
  const target = meta.resources[model.fields[name]?.relation?.resource ?? ""];
@@ -74,6 +81,7 @@ const LoadedCreate = ({ resource, values }) => {
74
81
  ]);
75
82
  const change = (name, value) => {
76
83
  setErrors((current) => without(current, name));
84
+ setTouched(true);
77
85
  setDraft((current) => ({
78
86
  ...current,
79
87
  [name]: value
@@ -97,11 +105,11 @@ const LoadedCreate = ({ resource, values }) => {
97
105
  "facets",
98
106
  "series"
99
107
  ]) queryClient.invalidateQueries({ queryKey: [prefix, resource] });
100
- go({
108
+ setNewRecord({
101
109
  kind: "record",
102
110
  resource,
103
111
  key: recordKey(model, created.record)
104
- }, { replace: true });
112
+ });
105
113
  });
106
114
  const formSections = sections.map((section) => ({
107
115
  title: section.title,
@@ -47,8 +47,14 @@ const text = defineMessages({
47
47
  loading: "Loading",
48
48
  leaveTitle: "Leave without saving?",
49
49
  leaveDescription: "You have unsaved changes on this page. They stay in this browser, and come back when you open the record again.",
50
+ leaveInputLost: "You have unsaved changes on this page. They are lost when you leave.",
50
51
  stay: "Stay",
51
52
  leave: "Leave",
53
+ reloadTitle: "Reload without saving?",
54
+ reloadDraftKept: "You have unsaved changes on this page. They stay in this browser, and come back after the page reloads.",
55
+ reloadInputLost: "You have unsaved changes on this page. They are lost when the page reloads.",
56
+ keepEditing: "Keep editing",
57
+ reload: "Reload",
52
58
  show: (label) => `Show ${label}`,
53
59
  hide: (label) => `Hide ${label}`,
54
60
  couldNotShow: (label) => `Could not show the ${label}`,
@@ -110,8 +116,14 @@ const text = defineMessages({
110
116
  loading: "Laden",
111
117
  leaveTitle: "Weggaan zonder te bewaren?",
112
118
  leaveDescription: "Je hebt niet-bewaarde wijzigingen op deze pagina. Ze blijven in deze browser, en komen terug als je het record weer opent.",
119
+ leaveInputLost: "Je hebt niet-bewaarde wijzigingen op deze pagina. Ze gaan verloren als je weggaat.",
113
120
  stay: "Blijven",
114
121
  leave: "Weggaan",
122
+ reloadTitle: "Opnieuw laden zonder op te slaan?",
123
+ reloadDraftKept: "Er zijn niet-opgeslagen wijzigingen op deze pagina. Ze blijven bewaard in deze browser en komen terug zodra de pagina opnieuw is geladen.",
124
+ reloadInputLost: "Er zijn niet-opgeslagen wijzigingen op deze pagina. Ze gaan verloren als de pagina opnieuw wordt geladen.",
125
+ keepEditing: "Verder bewerken",
126
+ reload: "Opnieuw laden",
115
127
  show: (label) => `${label} tonen`,
116
128
  hide: (label) => `${label} verbergen`,
117
129
  couldNotShow: (label) => `Kon ${label} niet tonen`,
@@ -0,0 +1,20 @@
1
+ import { defineMessages } from "../../locale/messages.js";
2
+ //#region src/app/new-version/messages.ts
3
+ const text = defineMessages({
4
+ en: {
5
+ title: "This app has been updated",
6
+ description: "Reload the page to use the new version, when it suits you.",
7
+ reload: "Reload",
8
+ reloadPage: "Reload page",
9
+ reloadPageHint: "Reload page to use the new version"
10
+ },
11
+ nl: {
12
+ title: "Deze app is bijgewerkt",
13
+ description: "Laad de pagina opnieuw voor de nieuwe versie, wanneer het uitkomt.",
14
+ reload: "Opnieuw laden",
15
+ reloadPage: "Opnieuw laden",
16
+ reloadPageHint: "Opnieuw laden om de nieuwe versie te gebruiken"
17
+ }
18
+ });
19
+ //#endregion
20
+ export { text };
@@ -0,0 +1,27 @@
1
+ import { Button } from "../../primitives/button.js";
2
+ import { useRouter } from "../router.js";
3
+ import { useAdminMeta } from "../meta-gate.js";
4
+ import { text } from "./messages.js";
5
+ import { RotateCw } from "lucide-react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ //#region src/app/new-version/new-version-button.tsx
8
+ /** The top bar's Reload page button, from the moment the page keeps its `/meta` for a new version until it reloads. */
9
+ const NewVersionButton = () => {
10
+ const changed = Boolean(useAdminMeta().versionChange);
11
+ const { reload } = useRouter();
12
+ if (!changed) return null;
13
+ return /* @__PURE__ */ jsxs(Button, {
14
+ size: "sm",
15
+ variant: "ghost",
16
+ "aria-label": text("reloadPageHint"),
17
+ title: text("reloadPageHint"),
18
+ onClick: reload,
19
+ className: "max-md:size-9 max-md:p-0",
20
+ children: [/* @__PURE__ */ jsx(RotateCw, { className: "size-3.5 text-primary" }), /* @__PURE__ */ jsx("span", {
21
+ className: "max-md:sr-only",
22
+ children: text("reloadPage")
23
+ })]
24
+ });
25
+ };
26
+ //#endregion
27
+ export { NewVersionButton };
@@ -0,0 +1,43 @@
1
+ import { useRouter } from "../router.js";
2
+ import { useAdminMeta } from "../meta-gate.js";
3
+ import { useToast } from "../../toasts/toast-provider.js";
4
+ import { text } from "./messages.js";
5
+ import { useModalOpen } from "./use-modal-open.js";
6
+ import { useEffect, useState } from "react";
7
+ //#region src/app/new-version/new-version-toast.tsx
8
+ /**
9
+ * Once the server runs another Protobase version than the page started with, a toast that says so and reloads the
10
+ * page, asking first while a guard holds unsaved changes. It stays until Reload or ×, and after × it does not come
11
+ * back; the top bar's `NewVersionButton` stays. It steps aside while a modal is open, whose buttons it could cover on a
12
+ * phone. The page keeps the `/meta` it has meanwhile (see `keepOnVersionChange`).
13
+ */
14
+ const NewVersionToast = () => {
15
+ const changed = Boolean(useAdminMeta().versionChange);
16
+ const modalOpen = useModalOpen();
17
+ const [closed, setClosed] = useState(false);
18
+ const toast = useToast();
19
+ const { reload } = useRouter();
20
+ const shown = changed && !closed && !modalOpen;
21
+ useEffect(() => {
22
+ if (!shown) return;
23
+ const id = toast.show({
24
+ state: "info",
25
+ sticky: true,
26
+ title: text("title"),
27
+ description: text("description"),
28
+ action: {
29
+ label: text("reload"),
30
+ onClick: reload
31
+ },
32
+ onClose: () => setClosed(true)
33
+ });
34
+ return () => toast.dismiss(id);
35
+ }, [
36
+ shown,
37
+ toast,
38
+ reload
39
+ ]);
40
+ return null;
41
+ };
42
+ //#endregion
43
+ export { NewVersionToast };
@@ -0,0 +1,19 @@
1
+ import { useEffect, useState } from "react";
2
+ //#region src/app/new-version/use-modal-open.ts
3
+ const modalOpen = () => document.querySelector("[aria-modal=\"true\"]") !== null;
4
+ /** Whether a modal, a dialog or a sheet, is open anywhere on the page, following them as they open and close. */
5
+ const useModalOpen = () => {
6
+ const [open, setOpen] = useState(modalOpen);
7
+ useEffect(() => {
8
+ const observer = new MutationObserver(() => setOpen(modalOpen()));
9
+ observer.observe(document.body, {
10
+ childList: true,
11
+ subtree: true
12
+ });
13
+ setOpen(modalOpen());
14
+ return () => observer.disconnect();
15
+ }, []);
16
+ return open;
17
+ };
18
+ //#endregion
19
+ export { useModalOpen };
@@ -1,5 +1,6 @@
1
1
  import { Button } from "../../../primitives/button.js";
2
2
  import { Dialog } from "../../../primitives/dialog.js";
3
+ import { useNavigationGuard } from "../../router.js";
3
4
  import { useClient } from "../../../data/api-provider.js";
4
5
  import { useAdminMeta, usePermissions } from "../../meta-gate.js";
5
6
  import { text } from "../messages.js";
@@ -45,6 +46,8 @@ const ModalFormDialog = ({ model, record, mode, label, title, fields, values, on
45
46
  const formRef = useFocusFirstInvalid(rejections);
46
47
  const [failure, setFailure] = useState();
47
48
  const [saving, setSaving] = useState(false);
49
+ const [touched, setTouched] = useState(false);
50
+ useNavigationGuard(touched || saving, "input-lost");
48
51
  const submit = async () => {
49
52
  setSaving(true);
50
53
  setFailure(void 0);
@@ -113,6 +116,7 @@ const ModalFormDialog = ({ model, record, mode, label, title, fields, values, on
113
116
  resource: model.name,
114
117
  onChange: (next) => {
115
118
  setErrors(({ [name]: _, ...rest }) => rest);
119
+ setTouched(true);
116
120
  setDraft((current) => ({
117
121
  ...current,
118
122
  [name]: next
@@ -153,7 +153,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
153
153
  const restore = useRestore(model);
154
154
  const canUpdate = can(permissions, "update") && stored.permissions?.update !== false;
155
155
  const { shown, offersEdit, editing } = recordShown(view?.recordMode, canUpdate, address?.kind === "record" && address.edit === true);
156
- useNavigationGuard(editor.dirty && shown === "form");
156
+ useNavigationGuard(editor.dirty && shown === "form" || editor.saving);
157
157
  usePauseFocusRefresh(editor.dirty);
158
158
  const openGroups = address?.kind === "record" ? address.open : void 0;
159
159
  const atEdit = address?.kind === "record" && address.edit === true;
@@ -34,13 +34,15 @@ const Router = ({ basePath = "", initialUrl, children }) => {
34
34
  url: initialUrl ?? browserUrl(),
35
35
  entry: memory ? newEntry() : historyEntry()
36
36
  }));
37
- const guards = useRef(/* @__PURE__ */ new Set());
37
+ const guards = useRef(/* @__PURE__ */ new Map());
38
+ const losesInput = () => [...guards.current.values()].includes("input-lost");
38
39
  const current = useRef(page);
39
40
  current.current = page;
40
41
  const position = useRef(memory ? 0 : historyIndex());
41
42
  const undoing = useRef(false);
42
43
  const proceeding = useRef(false);
43
44
  const leaveAfterUndo = useRef(void 0);
45
+ const reloading = useRef(false);
44
46
  const [pending, setPending] = useState();
45
47
  useEffect(() => {
46
48
  if (memory) return;
@@ -64,7 +66,8 @@ const Router = ({ basePath = "", initialUrl, children }) => {
64
66
  window.history.go(-delta);
65
67
  setPending({
66
68
  kind: "history",
67
- delta
69
+ delta,
70
+ inputLost: losesInput()
68
71
  });
69
72
  return;
70
73
  }
@@ -97,19 +100,32 @@ const Router = ({ basePath = "", initialUrl, children }) => {
97
100
  if (pathOf(full) !== pathOf(current.current.url) && guards.current.size > 0) return setPending({
98
101
  kind: "link",
99
102
  full,
100
- replace
103
+ replace,
104
+ inputLost: losesInput()
101
105
  });
102
106
  go(full, replace);
103
107
  }, [basePath, go]);
104
- const registerGuard = useCallback(() => {
108
+ const registerGuard = useCallback((kind = "draft-kept") => {
105
109
  const token = Symbol("guard");
106
- guards.current.add(token);
110
+ guards.current.set(token, kind);
107
111
  return () => void guards.current.delete(token);
108
112
  }, []);
113
+ const reload = useCallback(() => {
114
+ if (guards.current.size > 0) return setPending({
115
+ kind: "reload",
116
+ inputLost: losesInput()
117
+ });
118
+ window.location.reload();
119
+ }, []);
120
+ const reloadConfirmed = useCallback(() => reloading.current, []);
109
121
  const leave = () => {
110
122
  if (!pending) return;
111
123
  setPending(void 0);
112
124
  if (pending.kind === "link") return go(pending.full, pending.replace);
125
+ if (pending.kind === "reload") {
126
+ reloading.current = true;
127
+ return window.location.reload();
128
+ }
113
129
  proceeding.current = true;
114
130
  if (undoing.current) leaveAfterUndo.current = pending.delta;
115
131
  else window.history.go(pending.delta);
@@ -122,29 +138,49 @@ const Router = ({ basePath = "", initialUrl, children }) => {
122
138
  basePath,
123
139
  entry: page.entry,
124
140
  navigate,
125
- registerGuard
141
+ registerGuard,
142
+ reload,
143
+ reloadConfirmed
126
144
  };
127
145
  }, [
128
146
  page,
129
147
  basePath,
130
148
  navigate,
131
- registerGuard
149
+ registerGuard,
150
+ reload,
151
+ reloadConfirmed
132
152
  ]);
133
153
  return /* @__PURE__ */ jsxs(RouterContext.Provider, {
134
154
  value,
135
- children: [children, pending && /* @__PURE__ */ jsx(Dialog, {
136
- title: text("leaveTitle"),
137
- description: text("leaveDescription"),
138
- onClose: () => setPending(void 0),
139
- actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
140
- onClick: () => setPending(void 0),
141
- children: text("stay")
142
- }), /* @__PURE__ */ jsx(Button, {
143
- variant: "primary",
144
- onClick: leave,
145
- children: text("leave")
146
- })] })
147
- })]
155
+ children: [
156
+ children,
157
+ pending?.kind === "reload" && /* @__PURE__ */ jsx(Dialog, {
158
+ title: text("reloadTitle"),
159
+ description: text(pending.inputLost ? "reloadInputLost" : "reloadDraftKept"),
160
+ onClose: () => setPending(void 0),
161
+ actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
162
+ onClick: () => setPending(void 0),
163
+ children: text("keepEditing")
164
+ }), /* @__PURE__ */ jsx(Button, {
165
+ variant: "primary",
166
+ onClick: leave,
167
+ children: text("reload")
168
+ })] })
169
+ }),
170
+ pending && pending.kind !== "reload" && /* @__PURE__ */ jsx(Dialog, {
171
+ title: text("leaveTitle"),
172
+ description: text(pending.inputLost ? "leaveInputLost" : "leaveDescription"),
173
+ onClose: () => setPending(void 0),
174
+ actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
175
+ onClick: () => setPending(void 0),
176
+ children: text("stay")
177
+ }), /* @__PURE__ */ jsx(Button, {
178
+ variant: "primary",
179
+ onClick: leave,
180
+ children: text("leave")
181
+ })] })
182
+ })
183
+ ]
148
184
  });
149
185
  };
150
186
  /** A link to a path below the base path, as `appPath` writes it, that the router follows without reloading the page. */
@@ -158,16 +194,22 @@ const Link = ({ to, replace, ...rest }) => {
158
194
  ...rest
159
195
  });
160
196
  };
161
- /** While `active`, leaving the page asks first (in-app links and closing the tab). */
162
- const useNavigationGuard = (active) => {
163
- const { registerGuard } = useRouter();
164
- useEffect(() => active ? registerGuard() : void 0, [active, registerGuard]);
197
+ /** While `active`, leaving the page asks first (in-app links, a reload and closing the tab). */
198
+ const useNavigationGuard = (active, kind = "draft-kept") => {
199
+ const { registerGuard, reloadConfirmed } = useRouter();
200
+ useEffect(() => active ? registerGuard(kind) : void 0, [
201
+ active,
202
+ kind,
203
+ registerGuard
204
+ ]);
165
205
  useEffect(() => {
166
206
  if (!active) return;
167
- const warn = (event) => event.preventDefault();
207
+ const warn = (event) => {
208
+ if (!reloadConfirmed()) event.preventDefault();
209
+ };
168
210
  window.addEventListener("beforeunload", warn);
169
211
  return () => window.removeEventListener("beforeunload", warn);
170
- }, [active]);
212
+ }, [active, reloadConfirmed]);
171
213
  };
172
214
  //#endregion
173
215
  export { Link, Router, useNavigationGuard, useRouter };
@@ -66,6 +66,7 @@ const ApiProvider = ({ baseUrl, client: given, sources, fetch, queryClient: give
66
66
  const previous = metaVersion.current;
67
67
  metaVersion.current = version;
68
68
  if (previous === void 0 || previous === version) return;
69
+ if (queryClient.getQueryData(keys.meta)?.versionChange) return;
69
70
  queryClient.invalidateQueries({ queryKey: keys.meta }, { cancelRefetch: false });
70
71
  }
71
72
  }), [
@@ -7,6 +7,15 @@ export type MetaData = {
7
7
  etag: string;
8
8
  /** The app's name, which ends the tab titles. */
9
9
  appName: string;
10
+ /** The Protobase version the server ran when this `/meta` was read. */
11
+ protobaseVersion?: string;
12
+ /**
13
+ * Set once the server runs another Protobase version than the page started with: `to` is that version, when the server
14
+ * names one. The page keeps this `/meta` and stops reading it until it is reloaded (see `keepOnVersionChange`).
15
+ */
16
+ versionChange?: {
17
+ to?: string;
18
+ };
10
19
  /** The path of the app's own icon, when the project has one, which the sidebar's logo draws. */
11
20
  appIcon?: string;
12
21
  resources: Record<string, ResourceModel>;
@@ -3,8 +3,9 @@ import { UseQueryResult } from "@tanstack/react-query";
3
3
  //#region src/data/use-meta.d.ts
4
4
  /**
5
5
  * The resource and view models. Refetches on window focus with `If-None-Match`, so an unchanged
6
- * `/meta` costs a 304, and the cached object keeps its identity. Below the app's `MetaGate`, `data` holds the resources
7
- * of its sources too, as every page of the app reads them.
6
+ * `/meta` costs a 304, and the cached object keeps its identity. Once the server runs another Protobase version than
7
+ * the page started with, it keeps the `/meta` it has and stops reading it (see `keepOnVersionChange`). Below the app's
8
+ * `MetaGate`, `data` holds the resources of its sources too, as every page of the app reads them.
8
9
  */
9
10
  export declare const useMeta: () => UseQueryResult<MetaData>;
10
11
  //#endregion
@@ -1,13 +1,15 @@
1
1
  import { keys } from "./query-keys.js";
2
2
  import { useClient } from "./api-provider.js";
3
3
  import { MetaContext } from "./meta-context.js";
4
+ import { keepOnVersionChange } from "./version-freeze.js";
4
5
  import { useContext, useMemo } from "react";
5
6
  import { useQuery, useQueryClient } from "@tanstack/react-query";
6
7
  //#region src/data/use-meta.ts
7
8
  /**
8
9
  * The resource and view models. Refetches on window focus with `If-None-Match`, so an unchanged
9
- * `/meta` costs a 304, and the cached object keeps its identity. Below the app's `MetaGate`, `data` holds the resources
10
- * of its sources too, as every page of the app reads them.
10
+ * `/meta` costs a 304, and the cached object keeps its identity. Once the server runs another Protobase version than
11
+ * the page started with, it keeps the `/meta` it has and stops reading it (see `keepOnVersionChange`). Below the app's
12
+ * `MetaGate`, `data` holds the resources of its sources too, as every page of the app reads them.
11
13
  */
12
14
  const useMeta = () => {
13
15
  const client = useClient();
@@ -17,6 +19,7 @@ const useMeta = () => {
17
19
  queryKey: keys.meta,
18
20
  staleTime: 0,
19
21
  refetchOnWindowFocus: "always",
22
+ enabled: (current) => !current.state.data?.versionChange,
20
23
  queryFn: async () => {
21
24
  const previous = queryClient.getQueryData(keys.meta);
22
25
  const result = await client.meta(previous?.etag);
@@ -24,9 +27,10 @@ const useMeta = () => {
24
27
  if (!previous) throw new Error("The server answered 304 to a request without If-None-Match");
25
28
  return previous;
26
29
  }
27
- return {
30
+ return keepOnVersionChange(previous, {
28
31
  etag: result.etag,
29
32
  appName: result.meta.appName,
33
+ ...result.meta.protobaseVersion && { protobaseVersion: result.meta.protobaseVersion },
30
34
  ...result.meta.appIcon && { appIcon: result.meta.appIcon },
31
35
  resources: Object.fromEntries(result.meta.resources.map((model) => [model.name, model])),
32
36
  views: Object.fromEntries(result.meta.views.map((model) => [model.resource, model])),
@@ -46,7 +50,7 @@ const useMeta = () => {
46
50
  source,
47
51
  state: "loading"
48
52
  })) }
49
- };
53
+ });
50
54
  }
51
55
  });
52
56
  return useMemo(() => merged && query.data ? {
@@ -0,0 +1,17 @@
1
+ //#region src/data/version-freeze.ts
2
+ /**
3
+ * The `/meta` the page keeps after reading it again: `next`, unless the server now runs another Protobase version than
4
+ * the page started with. The page then keeps the `/meta` it has, marked with the version the server moved to, and
5
+ * `useMeta` stops reading it, so nothing on the page changes until it is reloaded.
6
+ */
7
+ const keepOnVersionChange = (previous, next) => {
8
+ if (!previous) return next;
9
+ if (previous.versionChange) return previous;
10
+ if (next.protobaseVersion === previous.protobaseVersion) return next;
11
+ return {
12
+ ...previous,
13
+ versionChange: { ...next.protobaseVersion && { to: next.protobaseVersion } }
14
+ };
15
+ };
16
+ //#endregion
17
+ export { keepOnVersionChange };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.40.5";
2
+ var version = "0.40.7";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };