@peers-app/peers-ui 0.25.19 → 0.25.21

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.
@@ -1,5 +1,5 @@
1
1
  import { type IDoc, type IPackage } from "@peers-app/peers-sdk";
2
- /** Lists package versions with activate, promote, pin, edit-semver, and delete actions. */
2
+ /** Lists package versions with activate, promote, pin, and delete actions. */
3
3
  export declare const PackageVersionsList: (props: {
4
4
  pkg: IDoc<IPackage>;
5
5
  }) => import("react/jsx-runtime").JSX.Element;
@@ -39,7 +39,7 @@ function tagBadgeClass(tag) {
39
39
  return "text-bg-success";
40
40
  return "text-bg-secondary";
41
41
  }
42
- /** Lists package versions with activate, promote, pin, edit-semver, and delete actions. */
42
+ /** Lists package versions with activate, promote, pin, and delete actions. */
43
43
  const PackageVersionsList = (props) => {
44
44
  const { pkg } = props;
45
45
  const refreshKey = (0, hooks_1.useObservableState)(0);
@@ -157,35 +157,6 @@ const PackageVersionsList = (props) => {
157
157
  alert(`Failed to promote version: ${err instanceof Error ? err.message : String(err)}`);
158
158
  }
159
159
  }
160
- async function updateVersion(pv, newSemver) {
161
- try {
162
- const pvHash = (0, peers_sdk_1.computePackageVersionHash)(newSemver, pv.versionTag || "dev", pv.packageBundleFileHash, pv.routesBundleFileHash, pv.uiBundleFileHash);
163
- // Semver is part of the author-signed payload — re-sign or clear the old signature.
164
- const authorSig = await peers_sdk_1.rpcServerCalls.signPackageAuthorVersion({
165
- packageId: pv.packageId,
166
- packageVersionId: pv.packageVersionId,
167
- version: newSemver,
168
- versionTag: pv.versionTag,
169
- packageBundleFileHash: pv.packageBundleFileHash,
170
- routesBundleFileHash: pv.routesBundleFileHash,
171
- uiBundleFileHash: pv.uiBundleFileHash,
172
- appNavs: pv.appNavs,
173
- createdBy: pv.createdBy,
174
- createdAt: pv.createdAt,
175
- });
176
- const updated = {
177
- ...pv,
178
- version: newSemver,
179
- packageVersionHash: pvHash,
180
- packageAuthorSignature: authorSig,
181
- };
182
- await (0, peers_sdk_1.PackageVersions)().signAndSave(updated, { saveAsSnapshot: true });
183
- refreshKey(refreshKey() + 1);
184
- }
185
- catch (err) {
186
- alert(`Failed to update version: ${err instanceof Error ? err.message : String(err)}`);
187
- }
188
- }
189
160
  async function pinVersion() {
190
161
  setPinning(true);
191
162
  try {
@@ -216,7 +187,7 @@ const PackageVersionsList = (props) => {
216
187
  if (sorted.length === 0) {
217
188
  return ((0, jsx_runtime_1.jsxs)("div", { className: "text-muted text-center p-4", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-tag me-1" }), "No versions found for this package."] }));
218
189
  }
219
- return ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsxs)("small", { className: "text-muted", children: [sorted.length, " version", sorted.length !== 1 ? "s" : ""] }), (0, jsx_runtime_1.jsx)("div", { className: "list-group mt-2", children: sorted.map((pv) => ((0, jsx_runtime_1.jsx)(VersionRow, { pv: pv, isActive: pv.packageVersionId === effectiveActiveVersionId, isPinned: isPinned, pinning: pinning, activating: activating, deleting: deleting, userMap: userMap, onActivate: activateVersion, onDelete: deleteVersion, onPin: pinVersion, onPromote: promoteVersion, onUpdateVersion: updateVersion }, pv.packageVersionId))) })] }));
190
+ return ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsxs)("small", { className: "text-muted", children: [sorted.length, " version", sorted.length !== 1 ? "s" : ""] }), (0, jsx_runtime_1.jsx)("div", { className: "list-group mt-2", children: sorted.map((pv) => ((0, jsx_runtime_1.jsx)(VersionRow, { pv: pv, isActive: pv.packageVersionId === effectiveActiveVersionId, isPinned: isPinned, pinning: pinning, activating: activating, deleting: deleting, userMap: userMap, onActivate: activateVersion, onDelete: deleteVersion, onPin: pinVersion, onPromote: promoteVersion }, pv.packageVersionId))) })] }));
220
191
  };
221
192
  exports.PackageVersionsList = PackageVersionsList;
222
193
  /**
@@ -236,25 +207,7 @@ function getPromoteTargets(currentTag) {
236
207
  function VersionRow(props) {
237
208
  const { pv, isActive, isPinned, pinning, activating, deleting, userMap } = props;
238
209
  const creatorName = userMap.get(pv.createdBy);
239
- const [editingVersion, setEditingVersion] = (0, react_1.useState)(false);
240
- const [versionDraft, setVersionDraft] = (0, react_1.useState)(pv.version);
241
- function startEditing() {
242
- setVersionDraft(pv.version);
243
- setEditingVersion(true);
244
- }
245
- function commitVersion() {
246
- const trimmed = versionDraft.trim();
247
- if (trimmed && trimmed !== pv.version) {
248
- props.onUpdateVersion(pv, trimmed);
249
- }
250
- setEditingVersion(false);
251
- }
252
210
  const currentTag = pv.versionTag || "dev";
253
211
  const promoteTargets = getPromoteTargets(currentTag);
254
- return ((0, jsx_runtime_1.jsx)("div", { className: `list-group-item ${isActive ? "list-group-item-success" : ""}`, children: (0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center justify-content-between", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center flex-wrap", children: [editingVersion ? ((0, jsx_runtime_1.jsx)("input", { type: "text", className: "form-control form-control-sm me-2", style: { width: "7em" }, value: versionDraft, onChange: (e) => setVersionDraft(e.target.value), onBlur: commitVersion, onKeyDown: (e) => {
255
- if (e.key === "Enter")
256
- commitVersion();
257
- if (e.key === "Escape")
258
- setEditingVersion(false);
259
- } })) : ((0, jsx_runtime_1.jsxs)("strong", { className: "me-2", style: { cursor: "pointer" }, onClick: startEditing, title: "Click to edit version", children: ["v", pv.version] })), pv.versionTag && ((0, jsx_runtime_1.jsx)("span", { className: `badge ${tagBadgeClass(pv.versionTag)} me-2`, children: pv.versionTag })), (0, jsx_runtime_1.jsx)("code", { className: "text-muted small me-2", children: pv.packageVersionHash?.substring(0, 8) }), (0, jsx_runtime_1.jsx)("small", { className: "text-muted me-2", children: formatDate(pv.createdAt) }), creatorName && (0, jsx_runtime_1.jsxs)("small", { className: "text-muted", children: ["by ", creatorName] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-1 align-items-center", children: [promoteTargets.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "dropdown", children: [(0, jsx_runtime_1.jsx)("button", { className: "btn btn-sm btn-outline-info dropdown-toggle", "data-bs-toggle": "dropdown", "data-bs-popper-config": '{"strategy":"fixed"}', title: "Change version tag", children: "Promote" }), (0, jsx_runtime_1.jsx)("ul", { className: "dropdown-menu dropdown-menu-end", children: promoteTargets.map((tag) => ((0, jsx_runtime_1.jsx)("li", { children: (0, jsx_runtime_1.jsxs)("button", { className: "dropdown-item", onClick: () => props.onPromote(pv, tag), children: [currentTag, " \u2192 ", tag] }) }, tag))) })] })), isActive ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("button", { className: "btn btn-sm btn-outline-secondary", disabled: isPinned || pinning, onClick: () => props.onPin(), title: isPinned ? "Already pinned" : "Pin to this version (disable auto-updates)", children: [pinning ? ((0, jsx_runtime_1.jsx)("span", { className: "spinner-border spinner-border-sm" })) : ((0, jsx_runtime_1.jsx)("i", { className: "bi bi-pin-fill" })), isPinned ? " Pinned" : " Pin"] }), (0, jsx_runtime_1.jsx)("span", { className: "badge text-bg-info align-self-center", children: "active" })] })) : ((0, jsx_runtime_1.jsxs)("button", { className: "btn btn-sm btn-outline-primary", disabled: activating === pv.packageVersionId, onClick: () => props.onActivate(pv), children: [activating === pv.packageVersionId ? ((0, jsx_runtime_1.jsx)("span", { className: "spinner-border spinner-border-sm me-1" })) : ((0, jsx_runtime_1.jsx)("i", { className: "bi bi-check2-circle me-1" })), "Activate"] })), !isActive && ((0, jsx_runtime_1.jsx)("button", { className: "btn btn-sm btn-outline-danger", disabled: deleting === pv.packageVersionId, onClick: () => props.onDelete(pv), children: deleting === pv.packageVersionId ? ((0, jsx_runtime_1.jsx)("span", { className: "spinner-border spinner-border-sm" })) : ((0, jsx_runtime_1.jsx)("i", { className: "bi bi-trash" })) }))] })] }) }));
212
+ return ((0, jsx_runtime_1.jsx)("div", { className: `list-group-item ${isActive ? "list-group-item-success" : ""}`, children: (0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center justify-content-between", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center flex-wrap", children: [(0, jsx_runtime_1.jsxs)("strong", { className: "me-2", children: ["v", pv.version] }), pv.versionTag && ((0, jsx_runtime_1.jsx)("span", { className: `badge ${tagBadgeClass(pv.versionTag)} me-2`, children: pv.versionTag })), (0, jsx_runtime_1.jsx)("code", { className: "text-muted small me-2", children: pv.packageVersionHash?.substring(0, 8) }), (0, jsx_runtime_1.jsx)("small", { className: "text-muted me-2", children: formatDate(pv.createdAt) }), creatorName && (0, jsx_runtime_1.jsxs)("small", { className: "text-muted", children: ["by ", creatorName] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-1 align-items-center", children: [promoteTargets.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "dropdown", children: [(0, jsx_runtime_1.jsx)("button", { className: "btn btn-sm btn-outline-info dropdown-toggle", "data-bs-toggle": "dropdown", "data-bs-popper-config": '{"strategy":"fixed"}', title: "Change version tag", children: "Promote" }), (0, jsx_runtime_1.jsx)("ul", { className: "dropdown-menu dropdown-menu-end", children: promoteTargets.map((tag) => ((0, jsx_runtime_1.jsx)("li", { children: (0, jsx_runtime_1.jsxs)("button", { className: "dropdown-item", onClick: () => props.onPromote(pv, tag), children: [currentTag, " \u2192 ", tag] }) }, tag))) })] })), isActive ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("button", { className: "btn btn-sm btn-outline-secondary", disabled: isPinned || pinning, onClick: () => props.onPin(), title: isPinned ? "Already pinned" : "Pin to this version (disable auto-updates)", children: [pinning ? ((0, jsx_runtime_1.jsx)("span", { className: "spinner-border spinner-border-sm" })) : ((0, jsx_runtime_1.jsx)("i", { className: "bi bi-pin-fill" })), isPinned ? " Pinned" : " Pin"] }), (0, jsx_runtime_1.jsx)("span", { className: "badge text-bg-info align-self-center", children: "active" })] })) : ((0, jsx_runtime_1.jsxs)("button", { className: "btn btn-sm btn-outline-primary", disabled: activating === pv.packageVersionId, onClick: () => props.onActivate(pv), children: [activating === pv.packageVersionId ? ((0, jsx_runtime_1.jsx)("span", { className: "spinner-border spinner-border-sm me-1" })) : ((0, jsx_runtime_1.jsx)("i", { className: "bi bi-check2-circle me-1" })), "Activate"] })), !isActive && ((0, jsx_runtime_1.jsx)("button", { className: "btn btn-sm btn-outline-danger", disabled: deleting === pv.packageVersionId, onClick: () => props.onDelete(pv), children: deleting === pv.packageVersionId ? ((0, jsx_runtime_1.jsx)("span", { className: "spinner-border spinner-border-sm" })) : ((0, jsx_runtime_1.jsx)("i", { className: "bi bi-trash" })) }))] })] }) }));
260
213
  }
@@ -167,7 +167,7 @@ const UIRouter = (props) => {
167
167
  if (!matchingRoute) {
168
168
  return undefined;
169
169
  }
170
- return UILoader({ peersUIId: matchingRoute.peersUIId, props: props.props });
170
+ return (0, jsx_runtime_1.jsx)(UILoader, { peersUIId: matchingRoute.peersUIId, props: props.props });
171
171
  };
172
172
  exports.UIRouter = UIRouter;
173
173
  const UILoader = (args) => {
@@ -177,22 +177,9 @@ const UILoader = (args) => {
177
177
  const { props, peersUIId } = args;
178
178
  const [lastReloadTime] = (0, hooks_1.useObservable)(globals_1.packageReloaded);
179
179
  props.__lastPackageReload = lastReloadTime;
180
- // check if peersUIId maps to an in-memory react component
181
- const peersUI = peersUIs[peersUIId];
182
- if (peersUI?.content) {
183
- const parseResult = peersUI.propsSchema.safeParse(props);
184
- if (!parseResult.success) {
185
- console.error("props did not match schema", {
186
- props,
187
- schema: peersUI.propsSchema,
188
- errors: parseResult.error,
189
- });
190
- return ((0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsxs)("pre", { children: ["Peers UI could not be loaded ", (0, jsx_runtime_1.jsx)("br", {}), "peersUIId: ", peersUI.peersUIId, " ", (0, jsx_runtime_1.jsx)("br", {}), "Component name: ", peersUI.content?.name, " ", (0, jsx_runtime_1.jsx)("br", {}), "Props did not match schema ", (0, jsx_runtime_1.jsx)("br", {}), "Errors: ", JSON.stringify(parseResult.error, null, 2), " ", (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("br", {}), "Props ", JSON.stringify((0, peers_sdk_1.toJSON)(props), null, 2), (0, jsx_runtime_1.jsx)("br", {})] }) }));
191
- }
192
- const Component = peersUI.content;
193
- return ((0, jsx_runtime_1.jsx)(UIErrorBoundary, { peersUIId: peersUIId, componentName: peersUI.content?.name, children: (0, jsx_runtime_1.jsx)(Component, { ...args.props }) }));
194
- }
195
- return (0, jsx_runtime_1.jsx)(UIAsyncLoader, { peersUIId: peersUIId, props: props });
180
+ // Keep the same loader ancestor after the bundle enters the cache. Replacing it
181
+ // on a path change would unmount the screen before its navigation guard can run.
182
+ return (0, jsx_runtime_1.jsx)(UIAsyncLoader, { peersUIId: peersUIId, props: props }, peersUIId);
196
183
  };
197
184
  const uiLoadingPromises = {};
198
185
  // Check if we're running in a React Native WebView (has injectUIBundle available)
@@ -273,6 +260,7 @@ function loadUIBundle(pkg, forceRefresh) {
273
260
  }
274
261
  const UIAsyncLoader = (props) => {
275
262
  const { peersUIId } = props;
263
+ const peersUI = peersUIs[peersUIId];
276
264
  const [loaded, setLoaded] = react_1.default.useState(false);
277
265
  const [loadError, setLoadError] = react_1.default.useState(null);
278
266
  const allRoutes = _window.getPeersUIRoutes();
@@ -280,7 +268,7 @@ const UIAsyncLoader = (props) => {
280
268
  (0, hooks_1.useObservable)(routes_loader_1.allPackages);
281
269
  const pkg = (0, routes_loader_1.allPackages)().find((p) => p.packageId === uiPackageId);
282
270
  (0, react_1.useEffect)(() => {
283
- if (!pkg)
271
+ if (!pkg || peersUIs[peersUIId]?.content)
284
272
  return;
285
273
  const pkgRef = pkg;
286
274
  setLoadError(null);
@@ -290,11 +278,11 @@ const UIAsyncLoader = (props) => {
290
278
  console.error(`Failed to load UI bundle for package ${pkgRef.name}:`, error);
291
279
  setLoadError(error);
292
280
  });
293
- }, [pkg?.packageId, pkg?.name, pkg]);
294
- if (!pkg) {
281
+ }, [peersUIId, pkg?.packageId, pkg?.name, pkg]);
282
+ if (!peersUI?.content && !pkg) {
295
283
  return (0, jsx_runtime_1.jsxs)("div", { children: ["Could not find package that owns peersUIId ", peersUIId] });
296
284
  }
297
- if (loadError) {
285
+ if (!peersUI?.content && loadError) {
298
286
  return ((0, jsx_runtime_1.jsxs)("div", { style: {
299
287
  padding: "20px",
300
288
  border: "2px solid #ff6b6b",
@@ -303,7 +291,7 @@ const UIAsyncLoader = (props) => {
303
291
  color: "#721c24",
304
292
  fontFamily: "monospace",
305
293
  margin: "10px 0",
306
- }, children: [(0, jsx_runtime_1.jsx)("h3", { style: { color: "#c53030", marginTop: 0 }, children: "Bundle Loading Error" }), (0, jsx_runtime_1.jsxs)("div", { style: { marginBottom: "15px" }, children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("strong", { children: "Peers UI ID:" }), " ", peersUIId] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("strong", { children: "Package:" }), " ", pkg.name] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("strong", { children: "Package ID:" }), " ", pkg.packageId] })] }), (0, jsx_runtime_1.jsxs)("details", { style: { marginBottom: "15px" }, children: [(0, jsx_runtime_1.jsx)("summary", { style: { cursor: "pointer", fontWeight: "bold", marginBottom: "10px" }, children: "Error Details" }), (0, jsx_runtime_1.jsxs)("div", { style: {
294
+ }, children: [(0, jsx_runtime_1.jsx)("h3", { style: { color: "#c53030", marginTop: 0 }, children: "Bundle Loading Error" }), (0, jsx_runtime_1.jsxs)("div", { style: { marginBottom: "15px" }, children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("strong", { children: "Peers UI ID:" }), " ", peersUIId] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("strong", { children: "Package:" }), " ", pkg?.name] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("strong", { children: "Package ID:" }), " ", pkg?.packageId] })] }), (0, jsx_runtime_1.jsxs)("details", { style: { marginBottom: "15px" }, children: [(0, jsx_runtime_1.jsx)("summary", { style: { cursor: "pointer", fontWeight: "bold", marginBottom: "10px" }, children: "Error Details" }), (0, jsx_runtime_1.jsxs)("div", { style: {
307
295
  backgroundColor: "#fed7d7",
308
296
  padding: "10px",
309
297
  borderRadius: "4px",
@@ -328,16 +316,15 @@ const UIAsyncLoader = (props) => {
328
316
  cursor: "pointer",
329
317
  }, children: "Retry Loading" })] }));
330
318
  }
331
- if (!loaded) {
319
+ if (!peersUI?.content && !loaded) {
332
320
  return (0, jsx_runtime_1.jsx)(loading_indicator_1.LoadingIndicator, {});
333
321
  }
334
- const peersUI = peersUIs[peersUIId];
335
322
  if (peersUI?.content) {
336
- const _props = { ...props, uiVar: undefined };
323
+ const _props = props.props;
337
324
  try {
338
325
  peersUI.propsSchema.parse(_props);
339
326
  const Component = peersUI.content;
340
- return ((0, jsx_runtime_1.jsx)(UIErrorBoundary, { peersUIId: peersUIId, packageName: pkg.name, componentName: peersUI.content?.name, children: (0, jsx_runtime_1.jsx)(Component, { ..._props }) }));
327
+ return ((0, jsx_runtime_1.jsx)(UIErrorBoundary, { peersUIId: peersUIId, packageName: pkg?.name, componentName: peersUI.content?.name, children: (0, jsx_runtime_1.jsx)(Component, { ..._props }) }));
341
328
  }
342
329
  catch (parseError) {
343
330
  return ((0, jsx_runtime_1.jsxs)("div", { style: {
@@ -348,7 +335,7 @@ const UIAsyncLoader = (props) => {
348
335
  color: "#721c24",
349
336
  fontFamily: "monospace",
350
337
  margin: "10px 0",
351
- }, children: [(0, jsx_runtime_1.jsx)("h3", { style: { color: "#c53030", marginTop: 0 }, children: "Props Validation Error" }), (0, jsx_runtime_1.jsxs)("div", { style: { marginBottom: "15px" }, children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("strong", { children: "Peers UI ID:" }), " ", peersUIId] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("strong", { children: "Package:" }), " ", pkg.name] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("strong", { children: "Component:" }), " ", peersUI.content?.name] })] }), (0, jsx_runtime_1.jsxs)("div", { style: {
338
+ }, children: [(0, jsx_runtime_1.jsx)("h3", { style: { color: "#c53030", marginTop: 0 }, children: "Props Validation Error" }), (0, jsx_runtime_1.jsxs)("div", { style: { marginBottom: "15px" }, children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("strong", { children: "Peers UI ID:" }), " ", peersUIId] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("strong", { children: "Package:" }), " ", pkg?.name] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("strong", { children: "Component:" }), " ", peersUI.content?.name] })] }), (0, jsx_runtime_1.jsxs)("div", { style: {
352
339
  backgroundColor: "#fed7d7",
353
340
  padding: "10px",
354
341
  borderRadius: "4px",
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const jsx_runtime_1 = require("react/jsx-runtime");
4
+ const react_1 = require("@testing-library/react");
5
+ const react_2 = require("react");
6
+ const mockPackages = [];
7
+ const mockGetFileContents = jest.fn();
8
+ jest.mock("@peers-app/peers-sdk", () => ({
9
+ debounceByArgs: (fn) => fn,
10
+ getUserContext: () => new Promise(() => { }),
11
+ packagePrefsVar: () => Object.assign(() => ({}), { loadingPromise: Promise.resolve() }),
12
+ PackageVersions: () => ({ get: async () => ({ uiBundleFileId: "bundle" }) }),
13
+ rpcServerCalls: { getFileContents: (...args) => mockGetFileContents(...args) },
14
+ }));
15
+ jest.mock("../globals", () => ({ mainContentPath: () => "", packageReloaded: () => 0 }));
16
+ jest.mock("../hooks", () => ({ useObservable: (value) => [value()] }));
17
+ jest.mock("../ui-defaults", () => ({}));
18
+ jest.mock("../components/loading-indicator", () => ({
19
+ LoadingIndicator: () => (0, jsx_runtime_1.jsx)("p", { children: "Loading bundle" }),
20
+ }));
21
+ jest.mock("./routes-loader", () => ({ allPackages: () => mockPackages }));
22
+ const ui_loader_1 = require("./ui-loader");
23
+ test.each([
24
+ false,
25
+ true,
26
+ ])("query changes retain screen state and props (preloaded: %s)", async (preloaded) => {
27
+ const packageId = `package-${preloaded}`;
28
+ const peersUIId = `ui-${preloaded}`;
29
+ const base = `package-nav/${packageId}/app`;
30
+ let path = `${base}?jobId=first`;
31
+ const listeners = new Set();
32
+ let mounts = 0;
33
+ let unmounts = 0;
34
+ function Content({ caller }) {
35
+ const [draft, setDraft] = (0, react_2.useState)("");
36
+ const currentPath = (0, react_2.useSyncExternalStore)((listener) => {
37
+ listeners.add(listener);
38
+ return () => {
39
+ listeners.delete(listener);
40
+ };
41
+ }, () => path);
42
+ (0, react_2.useEffect)(() => {
43
+ mounts++;
44
+ return () => {
45
+ unmounts++;
46
+ };
47
+ }, []);
48
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("p", { children: caller }), (0, jsx_runtime_1.jsx)("p", { children: currentPath }), (0, jsx_runtime_1.jsx)("input", { "aria-label": "Draft", value: draft, onChange: (event) => setDraft(event.target.value) })] }));
49
+ }
50
+ const schema = { parse: (value) => value, safeParse: () => ({ success: true }) };
51
+ const ui = { peersUIId, content: Content, propsSchema: schema };
52
+ const routeWindow = window;
53
+ routeWindow.registerPeersUIRoute({ packageId, peersUIId, path: base });
54
+ mockPackages.length = 0;
55
+ mockGetFileContents.mockReset();
56
+ let releaseBundle;
57
+ if (preloaded) {
58
+ await (0, ui_loader_1.registerInternalPeersUI)({ component: Content, peersUIId, propsSchema: schema });
59
+ }
60
+ else {
61
+ mockPackages.push({ packageId, name: "Test package", activePackageVersionId: "version" });
62
+ routeWindow.testUI = ui;
63
+ mockGetFileContents.mockImplementation(() => new Promise((resolve) => {
64
+ releaseBundle = resolve;
65
+ }));
66
+ }
67
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(ui_loader_1.UIRouter, { path: path, props: { caller: "Caller props" } }));
68
+ if (!preloaded) {
69
+ expect(await react_1.screen.findByText("Loading bundle")).toBeInTheDocument();
70
+ await (0, react_1.act)(async () => {
71
+ releaseBundle("exportUIs({uis: [window.testUI]});");
72
+ });
73
+ }
74
+ expect(await react_1.screen.findByText("Caller props")).toBeInTheDocument();
75
+ react_1.fireEvent.change(react_1.screen.getByLabelText("Draft"), { target: { value: "Unsaved edits" } });
76
+ await (0, react_1.act)(async () => {
77
+ path = `${base}?jobId=second`;
78
+ for (const listener of listeners)
79
+ listener();
80
+ view.rerender((0, jsx_runtime_1.jsx)(ui_loader_1.UIRouter, { path: path, props: { caller: "Caller props" } }));
81
+ });
82
+ expect(react_1.screen.getByLabelText("Draft")).toHaveValue("Unsaved edits");
83
+ expect(react_1.screen.getByText(path)).toBeInTheDocument();
84
+ expect({ mounts, unmounts, loads: mockGetFileContents.mock.calls.length }).toEqual({
85
+ mounts: 1,
86
+ unmounts: 0,
87
+ loads: preloaded ? 0 : 1,
88
+ });
89
+ view.unmount();
90
+ delete routeWindow.testUI;
91
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-ui",
3
- "version": "0.25.19",
3
+ "version": "0.25.21",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-ui.git"
@@ -30,7 +30,7 @@
30
30
  "lint:fix": "biome check --write ."
31
31
  },
32
32
  "peerDependencies": {
33
- "@peers-app/peers-sdk": "^0.25.19",
33
+ "@peers-app/peers-sdk": "^0.25.21",
34
34
  "bootstrap": "^5.3.3",
35
35
  "react": "^18.0.0",
36
36
  "react-dom": "^18.0.0"
@@ -41,7 +41,7 @@
41
41
  "@babel/preset-react": "^7.24.1",
42
42
  "@babel/preset-typescript": "^7.27.1",
43
43
  "@biomejs/biome": "2.3.11",
44
- "@peers-app/peers-sdk": "0.25.19",
44
+ "@peers-app/peers-sdk": "0.25.21",
45
45
  "@testing-library/dom": "^10.4.0",
46
46
  "@testing-library/jest-dom": "^6.6.3",
47
47
  "@testing-library/react": "^16.3.0",
@@ -43,7 +43,7 @@ function tagBadgeClass(tag?: string): string {
43
43
  return "text-bg-secondary";
44
44
  }
45
45
 
46
- /** Lists package versions with activate, promote, pin, edit-semver, and delete actions. */
46
+ /** Lists package versions with activate, promote, pin, and delete actions. */
47
47
  export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
48
48
  const { pkg } = props;
49
49
  const refreshKey = useObservableState(0);
@@ -174,41 +174,6 @@ export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
174
174
  }
175
175
  }
176
176
 
177
- async function updateVersion(pv: IPackageVersion, newSemver: string) {
178
- try {
179
- const pvHash = computePackageVersionHash(
180
- newSemver,
181
- pv.versionTag || "dev",
182
- pv.packageBundleFileHash,
183
- pv.routesBundleFileHash,
184
- pv.uiBundleFileHash,
185
- );
186
- // Semver is part of the author-signed payload — re-sign or clear the old signature.
187
- const authorSig = await rpcServerCalls.signPackageAuthorVersion({
188
- packageId: pv.packageId,
189
- packageVersionId: pv.packageVersionId,
190
- version: newSemver,
191
- versionTag: pv.versionTag,
192
- packageBundleFileHash: pv.packageBundleFileHash,
193
- routesBundleFileHash: pv.routesBundleFileHash,
194
- uiBundleFileHash: pv.uiBundleFileHash,
195
- appNavs: pv.appNavs,
196
- createdBy: pv.createdBy,
197
- createdAt: pv.createdAt,
198
- });
199
- const updated: IPackageVersion = {
200
- ...pv,
201
- version: newSemver,
202
- packageVersionHash: pvHash,
203
- packageAuthorSignature: authorSig,
204
- };
205
- await PackageVersions().signAndSave(updated, { saveAsSnapshot: true });
206
- refreshKey(refreshKey() + 1);
207
- } catch (err: unknown) {
208
- alert(`Failed to update version: ${err instanceof Error ? err.message : String(err)}`);
209
- }
210
- }
211
-
212
177
  async function pinVersion() {
213
178
  setPinning(true);
214
179
  try {
@@ -270,7 +235,6 @@ export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
270
235
  onDelete={deleteVersion}
271
236
  onPin={pinVersion}
272
237
  onPromote={promoteVersion}
273
- onUpdateVersion={updateVersion}
274
238
  />
275
239
  ))}
276
240
  </div>
@@ -305,26 +269,9 @@ function VersionRow(props: {
305
269
  onDelete: (pv: IPackageVersion) => void;
306
270
  onPin: () => void;
307
271
  onPromote: (pv: IPackageVersion, newTag: string) => void;
308
- onUpdateVersion: (pv: IPackageVersion, newSemver: string) => void;
309
272
  }) {
310
273
  const { pv, isActive, isPinned, pinning, activating, deleting, userMap } = props;
311
274
  const creatorName = userMap.get(pv.createdBy);
312
- const [editingVersion, setEditingVersion] = useState(false);
313
- const [versionDraft, setVersionDraft] = useState(pv.version);
314
-
315
- function startEditing() {
316
- setVersionDraft(pv.version);
317
- setEditingVersion(true);
318
- }
319
-
320
- function commitVersion() {
321
- const trimmed = versionDraft.trim();
322
- if (trimmed && trimmed !== pv.version) {
323
- props.onUpdateVersion(pv, trimmed);
324
- }
325
- setEditingVersion(false);
326
- }
327
-
328
275
  const currentTag = pv.versionTag || "dev";
329
276
  const promoteTargets = getPromoteTargets(currentTag);
330
277
 
@@ -332,29 +279,8 @@ function VersionRow(props: {
332
279
  <div className={`list-group-item ${isActive ? "list-group-item-success" : ""}`}>
333
280
  <div className="d-flex align-items-center justify-content-between">
334
281
  <div className="d-flex align-items-center flex-wrap">
335
- {editingVersion ? (
336
- <input
337
- type="text"
338
- className="form-control form-control-sm me-2"
339
- style={{ width: "7em" }}
340
- value={versionDraft}
341
- onChange={(e) => setVersionDraft(e.target.value)}
342
- onBlur={commitVersion}
343
- onKeyDown={(e) => {
344
- if (e.key === "Enter") commitVersion();
345
- if (e.key === "Escape") setEditingVersion(false);
346
- }}
347
- />
348
- ) : (
349
- <strong
350
- className="me-2"
351
- style={{ cursor: "pointer" }}
352
- onClick={startEditing}
353
- title="Click to edit version"
354
- >
355
- v{pv.version}
356
- </strong>
357
- )}
282
+ {/* The number is the version baked into the bundle. Relabeling it desyncs isolated packages. */}
283
+ <strong className="me-2">v{pv.version}</strong>
358
284
  {pv.versionTag && (
359
285
  <span className={`badge ${tagBadgeClass(pv.versionTag)} me-2`}>{pv.versionTag}</span>
360
286
  )}
@@ -0,0 +1,108 @@
1
+ import { act, fireEvent, render, screen } from "@testing-library/react";
2
+ import { useEffect, useState, useSyncExternalStore } from "react";
3
+
4
+ const mockPackages: Record<string, unknown>[] = [];
5
+ const mockGetFileContents = jest.fn();
6
+ jest.mock("@peers-app/peers-sdk", () => ({
7
+ debounceByArgs: (fn: unknown) => fn,
8
+ getUserContext: () => new Promise(() => {}),
9
+ packagePrefsVar: () => Object.assign(() => ({}), { loadingPromise: Promise.resolve() }),
10
+ PackageVersions: () => ({ get: async () => ({ uiBundleFileId: "bundle" }) }),
11
+ rpcServerCalls: { getFileContents: (...args: unknown[]) => mockGetFileContents(...args) },
12
+ }));
13
+ jest.mock("../globals", () => ({ mainContentPath: () => "", packageReloaded: () => 0 }));
14
+ jest.mock("../hooks", () => ({ useObservable: (value: () => unknown) => [value()] }));
15
+ jest.mock("../ui-defaults", () => ({}));
16
+ jest.mock("../components/loading-indicator", () => ({
17
+ LoadingIndicator: () => <p>Loading bundle</p>,
18
+ }));
19
+ jest.mock("./routes-loader", () => ({ allPackages: () => mockPackages }));
20
+
21
+ import { registerInternalPeersUI, UIRouter } from "./ui-loader";
22
+
23
+ test.each([
24
+ false,
25
+ true,
26
+ ])("query changes retain screen state and props (preloaded: %s)", async (preloaded) => {
27
+ const packageId = `package-${preloaded}`;
28
+ const peersUIId = `ui-${preloaded}`;
29
+ const base = `package-nav/${packageId}/app`;
30
+ let path = `${base}?jobId=first`;
31
+ const listeners = new Set<() => void>();
32
+ let mounts = 0;
33
+ let unmounts = 0;
34
+ function Content({ caller }: { caller: string }) {
35
+ const [draft, setDraft] = useState("");
36
+ const currentPath = useSyncExternalStore(
37
+ (listener) => {
38
+ listeners.add(listener);
39
+ return () => {
40
+ listeners.delete(listener);
41
+ };
42
+ },
43
+ () => path,
44
+ );
45
+ useEffect(() => {
46
+ mounts++;
47
+ return () => {
48
+ unmounts++;
49
+ };
50
+ }, []);
51
+ return (
52
+ <>
53
+ <p>{caller}</p>
54
+ <p>{currentPath}</p>
55
+ <input
56
+ aria-label="Draft"
57
+ value={draft}
58
+ onChange={(event) => setDraft(event.target.value)}
59
+ />
60
+ </>
61
+ );
62
+ }
63
+ const schema = { parse: (value: unknown) => value, safeParse: () => ({ success: true }) };
64
+ const ui = { peersUIId, content: Content, propsSchema: schema };
65
+ const routeWindow = window as unknown as {
66
+ registerPeersUIRoute: (route: object) => void;
67
+ testUI?: object;
68
+ };
69
+ routeWindow.registerPeersUIRoute({ packageId, peersUIId, path: base });
70
+ mockPackages.length = 0;
71
+ mockGetFileContents.mockReset();
72
+ let releaseBundle!: (code: string) => void;
73
+ if (preloaded) {
74
+ await registerInternalPeersUI({ component: Content, peersUIId, propsSchema: schema });
75
+ } else {
76
+ mockPackages.push({ packageId, name: "Test package", activePackageVersionId: "version" });
77
+ routeWindow.testUI = ui;
78
+ mockGetFileContents.mockImplementation(
79
+ () =>
80
+ new Promise<string>((resolve) => {
81
+ releaseBundle = resolve;
82
+ }),
83
+ );
84
+ }
85
+ const view = render(<UIRouter path={path} props={{ caller: "Caller props" }} />);
86
+ if (!preloaded) {
87
+ expect(await screen.findByText("Loading bundle")).toBeInTheDocument();
88
+ await act(async () => {
89
+ releaseBundle("exportUIs({uis: [window.testUI]});");
90
+ });
91
+ }
92
+ expect(await screen.findByText("Caller props")).toBeInTheDocument();
93
+ fireEvent.change(screen.getByLabelText("Draft"), { target: { value: "Unsaved edits" } });
94
+ await act(async () => {
95
+ path = `${base}?jobId=second`;
96
+ for (const listener of listeners) listener();
97
+ view.rerender(<UIRouter path={path} props={{ caller: "Caller props" }} />);
98
+ });
99
+ expect(screen.getByLabelText("Draft")).toHaveValue("Unsaved edits");
100
+ expect(screen.getByText(path)).toBeInTheDocument();
101
+ expect({ mounts, unmounts, loads: mockGetFileContents.mock.calls.length }).toEqual({
102
+ mounts: 1,
103
+ unmounts: 0,
104
+ loads: preloaded ? 0 : 1,
105
+ });
106
+ view.unmount();
107
+ delete routeWindow.testUI;
108
+ });
@@ -239,7 +239,7 @@ export const UIRouter = (props: IProps) => {
239
239
  return undefined;
240
240
  }
241
241
 
242
- return UILoader({ peersUIId: matchingRoute.peersUIId, props: props.props });
242
+ return <UILoader peersUIId={matchingRoute.peersUIId} props={props.props} />;
243
243
  };
244
244
 
245
245
  const UILoader = (args: { peersUIId: string; props?: Record<string, unknown> }) => {
@@ -249,40 +249,9 @@ const UILoader = (args: { peersUIId: string; props?: Record<string, unknown> })
249
249
  const { props, peersUIId } = args;
250
250
  const [lastReloadTime] = useObservable(packageReloaded);
251
251
  props.__lastPackageReload = lastReloadTime;
252
- // check if peersUIId maps to an in-memory react component
253
- const peersUI = peersUIs[peersUIId];
254
- if (peersUI?.content) {
255
- const parseResult = peersUI.propsSchema.safeParse(props);
256
- if (!parseResult.success) {
257
- console.error("props did not match schema", {
258
- props,
259
- schema: peersUI.propsSchema,
260
- errors: parseResult.error,
261
- });
262
- return (
263
- <div>
264
- <pre>
265
- Peers UI could not be loaded <br />
266
- peersUIId: {peersUI.peersUIId} <br />
267
- Component name: {peersUI.content?.name} <br />
268
- Props did not match schema <br />
269
- Errors: {JSON.stringify(parseResult.error, null, 2)} <br />
270
- <br />
271
- Props {JSON.stringify(toJSON(props), null, 2)}
272
- <br />
273
- </pre>
274
- </div>
275
- );
276
- }
277
- const Component = peersUI.content as React.ComponentType<Record<string, unknown>>;
278
- return (
279
- <UIErrorBoundary peersUIId={peersUIId} componentName={peersUI.content?.name}>
280
- <Component {...args.props} />
281
- </UIErrorBoundary>
282
- );
283
- }
284
-
285
- return <UIAsyncLoader peersUIId={peersUIId} props={props} />;
252
+ // Keep the same loader ancestor after the bundle enters the cache. Replacing it
253
+ // on a path change would unmount the screen before its navigation guard can run.
254
+ return <UIAsyncLoader key={peersUIId} peersUIId={peersUIId} props={props} />;
286
255
  };
287
256
 
288
257
  const uiLoadingPromises: Record<string, Promise<void>> = {};
@@ -379,6 +348,7 @@ function loadUIBundle(pkg: IPackage, forceRefresh?: boolean) {
379
348
 
380
349
  const UIAsyncLoader = (props: { peersUIId: string; props: Record<string, unknown> }) => {
381
350
  const { peersUIId } = props;
351
+ const peersUI = peersUIs[peersUIId];
382
352
  const [loaded, setLoaded] = React.useState(false);
383
353
  const [loadError, setLoadError] = React.useState<Error | null>(null);
384
354
 
@@ -389,7 +359,7 @@ const UIAsyncLoader = (props: { peersUIId: string; props: Record<string, unknown
389
359
  const pkg = allPackages().find((p) => p.packageId === uiPackageId);
390
360
 
391
361
  useEffect(() => {
392
- if (!pkg) return;
362
+ if (!pkg || peersUIs[peersUIId]?.content) return;
393
363
  const pkgRef = pkg;
394
364
  setLoadError(null);
395
365
  loadUIBundle(pkgRef)
@@ -398,13 +368,13 @@ const UIAsyncLoader = (props: { peersUIId: string; props: Record<string, unknown
398
368
  console.error(`Failed to load UI bundle for package ${pkgRef.name}:`, error);
399
369
  setLoadError(error);
400
370
  });
401
- }, [pkg?.packageId, pkg?.name, pkg]);
371
+ }, [peersUIId, pkg?.packageId, pkg?.name, pkg]);
402
372
 
403
- if (!pkg) {
373
+ if (!peersUI?.content && !pkg) {
404
374
  return <div>Could not find package that owns peersUIId {peersUIId}</div>;
405
375
  }
406
376
 
407
- if (loadError) {
377
+ if (!peersUI?.content && loadError) {
408
378
  return (
409
379
  <div
410
380
  style={{
@@ -423,10 +393,10 @@ const UIAsyncLoader = (props: { peersUIId: string; props: Record<string, unknown
423
393
  <strong>Peers UI ID:</strong> {peersUIId}
424
394
  </div>
425
395
  <div>
426
- <strong>Package:</strong> {pkg.name}
396
+ <strong>Package:</strong> {pkg?.name}
427
397
  </div>
428
398
  <div>
429
- <strong>Package ID:</strong> {pkg.packageId}
399
+ <strong>Package ID:</strong> {pkg?.packageId}
430
400
  </div>
431
401
  </div>
432
402
 
@@ -484,20 +454,19 @@ const UIAsyncLoader = (props: { peersUIId: string; props: Record<string, unknown
484
454
  );
485
455
  }
486
456
 
487
- if (!loaded) {
457
+ if (!peersUI?.content && !loaded) {
488
458
  return <LoadingIndicator />;
489
459
  }
490
460
 
491
- const peersUI = peersUIs[peersUIId];
492
461
  if (peersUI?.content) {
493
- const _props = { ...props, uiVar: undefined };
462
+ const _props = props.props;
494
463
  try {
495
464
  peersUI.propsSchema.parse(_props);
496
465
  const Component = peersUI.content as React.ComponentType<Record<string, unknown>>;
497
466
  return (
498
467
  <UIErrorBoundary
499
468
  peersUIId={peersUIId}
500
- packageName={pkg.name}
469
+ packageName={pkg?.name}
501
470
  componentName={peersUI.content?.name}
502
471
  >
503
472
  <Component {..._props} />
@@ -522,7 +491,7 @@ const UIAsyncLoader = (props: { peersUIId: string; props: Record<string, unknown
522
491
  <strong>Peers UI ID:</strong> {peersUIId}
523
492
  </div>
524
493
  <div>
525
- <strong>Package:</strong> {pkg.name}
494
+ <strong>Package:</strong> {pkg?.name}
526
495
  </div>
527
496
  <div>
528
497
  <strong>Component:</strong> {peersUI.content?.name}