@rebasepro/app 0.12.0 → 0.12.1-canary.g35be8cb

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/app",
3
3
  "type": "module",
4
- "version": "0.12.0",
4
+ "version": "0.12.1-canary.g35be8cb",
5
5
  "description": "Rebase core — framework-agnostic runtime for data-driven admin panels",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -18,7 +18,7 @@
18
18
  "types": "./dist/index.d.ts",
19
19
  "source": "src/index.ts",
20
20
  "engines": {
21
- "node": ">=20"
21
+ "node": ">=22.22.0"
22
22
  },
23
23
  "keywords": [
24
24
  "rebase",
@@ -45,48 +45,47 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "compressorjs": "^1.3.0",
48
- "fast-equals": "6.0.0",
49
- "fuse.js": "^7.4.2",
50
- "i18next": "^26.3.1",
51
- "lucide-react": "1.18.0",
48
+ "fast-equals": "6.0.2",
49
+ "fuse.js": "^7.5.0",
50
+ "i18next": "^26.3.6",
51
+ "lucide-react": "1.27.0",
52
52
  "magic-string": "^0.30.0",
53
53
  "notistack": "^3.0.2",
54
- "react-i18next": "^17.0.8",
55
- "@rebasepro/admin-types": "0.12.0",
56
- "@rebasepro/types": "0.12.0",
57
- "@rebasepro/ui": "0.12.0",
58
- "@rebasepro/forms": "0.12.0",
59
- "@rebasepro/utils": "0.12.0",
60
- "@rebasepro/common": "0.12.0"
54
+ "react-i18next": "^17.0.11",
55
+ "@rebasepro/common": "0.12.1-canary.g35be8cb",
56
+ "@rebasepro/admin-types": "0.12.1-canary.g35be8cb",
57
+ "@rebasepro/forms": "0.12.1-canary.g35be8cb",
58
+ "@rebasepro/types": "0.12.1-canary.g35be8cb",
59
+ "@rebasepro/ui": "0.12.1-canary.g35be8cb",
60
+ "@rebasepro/utils": "0.12.1-canary.g35be8cb"
61
61
  },
62
62
  "peerDependencies": {
63
- "react": ">=19.0.0",
64
- "react-dom": ">=19.0.0",
65
- "react-router": "^7.0.0",
66
- "react-router-dom": "^7.0.0",
63
+ "react": ">=19.2.7",
64
+ "react-dom": ">=19.2.7",
65
+ "react-router": "^8.3.0",
67
66
  "typescript": ">=5.0.0"
68
67
  },
69
68
  "devDependencies": {
70
69
  "@jest/globals": "^30.4.1",
71
- "@testing-library/jest-dom": "^6.9.1",
70
+ "@testing-library/jest-dom": "^7.0.0",
72
71
  "@testing-library/react": "^16.3.2",
73
72
  "@testing-library/user-event": "^14.6.1",
74
73
  "@types/jest": "^30.0.0",
75
- "@types/node": "^25.9.3",
74
+ "@types/node": "^26.1.2",
76
75
  "@types/react": "^19.2.17",
77
76
  "@types/react-dom": "^19.2.3",
78
77
  "@types/react-measure": "^2.0.12",
79
- "@vitejs/plugin-react": "^6.0.2",
78
+ "@vitejs/plugin-react": "^6.0.4",
80
79
  "babel-plugin-react-compiler": "beta",
81
80
  "cross-env": "^10.1.0",
82
81
  "esbuild": "^0.28.1",
83
82
  "jest": "^30.4.2",
84
83
  "jest-environment-jsdom": "^30.4.1",
85
- "react-router-dom": "^7.17.0",
86
- "ts-jest": "^29.4.11",
84
+ "react-router": "^8.3.0",
85
+ "ts-jest": "^29.4.12",
87
86
  "tsd": "^0.33.0",
88
87
  "typescript": "^6.0.3",
89
- "vite": "^8.0.16"
88
+ "vite": "^8.1.5"
90
89
  },
91
90
  "files": [
92
91
  "dist",
@@ -104,13 +103,15 @@
104
103
  },
105
104
  "jest": {
106
105
  "transform": {
107
- "^.+\\.tsx?$": "ts-jest"
106
+ "^.+\\.tsx?$": "ts-jest",
107
+ "^.+\\.m?jsx?$": "<rootDir>/../../scripts/jest/react-router-esm-transform.cjs"
108
108
  },
109
109
  "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$",
110
110
  "moduleFileExtensions": [
111
111
  "ts",
112
112
  "tsx",
113
113
  "js",
114
+ "mjs",
114
115
  "jsx",
115
116
  "json",
116
117
  "node"
@@ -127,7 +128,10 @@
127
128
  "\\.(css|less)$": "<rootDir>/test/__mocks__/styleMock.js",
128
129
  "^@rebasepro/([a-z0-9-]+)$": "<rootDir>/../$1/src/index.ts",
129
130
  "^@rebasepro/admin-types$": "<rootDir>/../admin-types/src/index.ts"
130
- }
131
+ },
132
+ "transformIgnorePatterns": [
133
+ "node_modules/(?!.*(?:react-router|cookie-es|fractional-indexing))"
134
+ ]
131
135
  },
132
136
  "scripts": {
133
137
  "watch": "vite build --watch",
package/src/auth/api.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { RebaseApiError } from "@rebasepro/types";
13
+ import { DEFAULT_API_PATH } from "../hooks/ApiConfigContext";
13
14
 
14
15
  /**
15
16
  * @deprecated Use {@link RebaseApiError} (from `@rebasepro/types`, re-exported by
@@ -113,7 +114,12 @@ export function createAuthConfigCache(): AuthConfigCache {
113
114
  * Concurrent calls are deduplicated: only one network request is made
114
115
  * and all callers share the same promise.
115
116
  */
116
- export async function fetchAuthConfig(apiUrl: string, cache: AuthConfigCache): Promise<AuthConfigResponse> {
117
+ export async function fetchAuthConfig(
118
+ apiUrl: string,
119
+ cache: AuthConfigCache,
120
+ /** The backend's `basePath`; only needed if it is not the default. */
121
+ apiPath: string = DEFAULT_API_PATH
122
+ ): Promise<AuthConfigResponse> {
117
123
  if (cache.cached) {
118
124
  return cache.cached;
119
125
  }
@@ -123,7 +129,7 @@ export async function fetchAuthConfig(apiUrl: string, cache: AuthConfigCache): P
123
129
  }
124
130
 
125
131
  cache.inflight = (async () => {
126
- const response = await fetchWithHandling(`${apiUrl}/api/auth/config`, {
132
+ const response = await fetchWithHandling(`${apiUrl.replace(/\/+$/, "")}${apiPath}/auth/config`, {
127
133
  method: "GET",
128
134
  headers: { "Content-Type": "application/json" }
129
135
  });
@@ -687,6 +687,14 @@ selected: true }, { name: "Tags" }].map(c => (
687
687
  <TextField label="With value" value="Filled value" onChange={() => {}}/>
688
688
  <TextField label="Error state" error value="Bad value" onChange={() => {}}/>
689
689
  <TextField label="Disabled" disabled value="Read only" onChange={() => {}}/>
690
+ {/* The smaller sizes are the ones dialogs use, and they
691
+ were absent here — which is how a labelled `small`
692
+ field shipped with its label sitting on top of its
693
+ placeholder. Keep all four so the collision is
694
+ visible on this page rather than in a modal. */}
695
+ <TextField size="medium" label="Medium" placeholder="Placeholder under the label"/>
696
+ <TextField size="small" label="Small" placeholder="Placeholder under the label"/>
697
+ <TextField size="smallest" label="Smallest" value="Filled value" onChange={() => {}}/>
690
698
  </div>
691
699
  </div>
692
700
  <div className="col-span-12 sm:col-span-6 flex flex-col gap-4">
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { Link } from "react-router-dom";
2
+ import { Link } from "react-router";
3
3
  import { Button, Typography } from "@rebasepro/ui";
4
4
  import { useTranslation } from "../hooks";
5
5
 
@@ -1,6 +1,6 @@
1
1
 
2
2
  import React, { useCallback, useEffect, useMemo, useState } from "react";
3
- import { useLocation } from "react-router-dom";
3
+ import { useLocation } from "react-router";
4
4
 
5
5
  import { useData, useRebaseContext } from "../../hooks";
6
6
  import { useDataOrder } from "../../hooks/data/useDataOrder";
@@ -415,7 +415,11 @@ export function Rebase<USER extends User>(props: RebaseProps<USER>) {
415
415
 
416
416
  if (resolvedApiUrl) {
417
417
  return (
418
- <ApiConfigProvider apiUrl={resolvedApiUrl} getAuthToken={authController.getAuthToken}>
418
+ <ApiConfigProvider
419
+ apiUrl={resolvedApiUrl}
420
+ apiPath={client?.apiPath}
421
+ getAuthToken={authController.getAuthToken}
422
+ >
419
423
  {content}
420
424
  </ApiConfigProvider>
421
425
  );
@@ -1,4 +1,5 @@
1
- import { createBrowserRouter, RouterProvider } from "react-router-dom"
1
+ import { createBrowserRouter } from "react-router"
2
+ import { RouterProvider } from "react-router/dom"
2
3
  import { removeInitialAndTrailingSlashes } from "@rebasepro/app";
3
4
 
4
5
  export function RebaseRouter({
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { Route, Routes, useLocation } from "react-router-dom";
2
+ import { Route, Routes, useLocation } from "react-router";
3
3
 
4
4
 
5
5
  /**
@@ -9,9 +9,24 @@ import React, { useContext } from "react";
9
9
  */
10
10
  export interface ApiConfig {
11
11
  apiUrl: string;
12
+ /**
13
+ * The path the backend mounts its API under, appended to {@link apiUrl}.
14
+ *
15
+ * `"/api"` by default, which is the server's default `basePath`. It is
16
+ * carried here because a good deal of UI builds request URLs by hand
17
+ * instead of going through the client's typed methods, and every one of
18
+ * those sites used to write `/api` as a literal — so a backend configured
19
+ * with any other `basePath` served an admin panel whose auth-provider
20
+ * discovery, storage browser, history panel, user picker and Studio tools
21
+ * all requested paths that did not exist.
22
+ */
23
+ apiPath: string;
12
24
  getAuthToken?: () => Promise<string | null>;
13
25
  }
14
26
 
27
+ /** What the server uses when `basePath` is not configured. */
28
+ export const DEFAULT_API_PATH = "/api";
29
+
15
30
  const ApiConfigContext = React.createContext<ApiConfig | undefined>(undefined);
16
31
 
17
32
  /**
@@ -23,16 +38,43 @@ export function useApiConfig(): ApiConfig | undefined {
23
38
  }
24
39
 
25
40
  /**
26
- * Provide API configuration (apiUrl, getAuthToken) to the entire subtree.
41
+ * `apiUrl` and `apiPath` joined, with no trailing slash — the prefix to build a
42
+ * request URL from.
43
+ *
44
+ * Returns `undefined` when there is no provider, so a caller can keep its own
45
+ * fallback rather than silently requesting a relative path.
46
+ */
47
+ export function useApiBase(): string | undefined {
48
+ const config = useApiConfig();
49
+ if (!config?.apiUrl) return undefined;
50
+ return config.apiUrl.replace(/\/+$/, "") + (config.apiPath || DEFAULT_API_PATH);
51
+ }
52
+
53
+ /**
54
+ * The same prefix, derived from a client rather than from context.
55
+ *
56
+ * For the code that holds a `RebaseClient` but sits outside (or above) an
57
+ * `ApiConfigProvider`. Returns `undefined` when the client has no base URL,
58
+ * which is the signal every caller already treats as "cannot build a request".
59
+ */
60
+ export function apiBaseOf(client?: { baseUrl?: string; apiPath?: string }): string | undefined {
61
+ if (!client?.baseUrl) return undefined;
62
+ return client.baseUrl.replace(/\/+$/, "") + (client.apiPath || DEFAULT_API_PATH);
63
+ }
64
+
65
+ /**
66
+ * Provide API configuration (apiUrl, apiPath, getAuthToken) to the entire subtree.
27
67
  * Typically rendered inside `<Rebase>` or at the app root.
28
68
  */
29
69
  export function ApiConfigProvider({
30
70
  apiUrl,
71
+ apiPath = DEFAULT_API_PATH,
31
72
  getAuthToken,
32
73
  children
33
- }: ApiConfig & { children: React.ReactNode }) {
74
+ }: Omit<ApiConfig, "apiPath"> & { apiPath?: string; children: React.ReactNode }) {
34
75
  const value = React.useMemo(() => ({ apiUrl,
35
- getAuthToken }), [apiUrl, getAuthToken]);
76
+ apiPath,
77
+ getAuthToken }), [apiUrl, apiPath, getAuthToken]);
36
78
  return (
37
79
  <ApiConfigContext.Provider value={value}>
38
80
  {children}
@@ -8,16 +8,42 @@ export function useAuthSubscription(authClient?: AuthClient): AuthController {
8
8
  const currentSession = authClient?.getSession();
9
9
  const [user, setUser] = useState<User | null>(currentSession?.user ?? null);
10
10
 
11
- const [initialLoading, setInitialLoading] = useState(!currentSession);
12
11
  const [authLoading, setAuthLoading] = useState(false);
13
12
  const [authError, setAuthError] = useState<Error>();
14
13
  const [loginSkipped, setLoginSkipped] = useState(false);
15
14
  const [extra, setExtra] = useState<any>();
16
15
 
16
+ /**
17
+ * Whether asking the server "who am I?" could possibly answer.
18
+ *
19
+ * A client configured with `persistSession: false` and JSON auth — the
20
+ * shape used when one client borrows another's credential, as the hosted
21
+ * Studio does — has no stored session and no auth cookie, so the probe
22
+ * below is guaranteed to 401. Sending it anyway put three failing
23
+ * `GET /auth/me` calls on the wire per mount, each one landing in the
24
+ * *customer's* request log as an authentication failure against their own
25
+ * backend.
26
+ *
27
+ * A client that does not implement the capability is treated as "might
28
+ * work" — the historical behaviour.
29
+ */
30
+ const mayHaveRestorableSession = authClient?.canRestoreSession?.() ?? true;
31
+
32
+ /**
33
+ * `true` only while a probe is genuinely outstanding.
34
+ *
35
+ * It used to be seeded from `!currentSession` alone, which left a client
36
+ * that cannot restore a session — and therefore never runs the probe that
37
+ * clears the flag — reporting "still loading" forever. Anything gated on
38
+ * `initialLoading` (a spinner, a redirect to the login view) would never
39
+ * resolve.
40
+ */
41
+ const [initialLoading, setInitialLoading] = useState(!currentSession && mayHaveRestorableSession);
42
+
17
43
  useEffect(() => {
18
44
  if (!authClient) return;
19
45
  // If we don't have a session initially, try to get user which restores session if a persistent token exists
20
- if (!currentSession) {
46
+ if (!currentSession && mayHaveRestorableSession) {
21
47
  setInitialLoading(true);
22
48
  authClient.getUser()
23
49
  .then(user => {
@@ -32,7 +58,7 @@ export function useAuthSubscription(authClient?: AuthClient): AuthController {
32
58
  setInitialLoading(false);
33
59
  });
34
60
  }
35
- }, [authClient, currentSession]);
61
+ }, [authClient, currentSession, mayHaveRestorableSession]);
36
62
 
37
63
  useEffect(() => {
38
64
  if (!authClient) return;
@@ -10,12 +10,18 @@ import {
10
10
  DownloadConfig,
11
11
  StorageListResult
12
12
  } from "@rebasepro/types";
13
+ import { DEFAULT_API_PATH } from "./ApiConfigContext";
13
14
 
14
15
  export interface BackendStorageSourceProps {
15
16
  /**
16
17
  * Backend API URL (e.g., 'http://localhost:3001')
17
18
  */
18
19
  apiUrl: string;
20
+ /**
21
+ * The path the backend mounts its API under. Defaults to the server's own
22
+ * default; pass the backend's `basePath` if it was configured otherwise.
23
+ */
24
+ apiPath?: string;
19
25
  /**
20
26
  * Function to get the current auth token
21
27
  */
@@ -39,10 +45,11 @@ export interface BackendStorageSourceProps {
39
45
  */
40
46
  export function useBackendStorageSource({
41
47
  apiUrl,
48
+ apiPath = DEFAULT_API_PATH,
42
49
  getAuthToken
43
50
  }: BackendStorageSourceProps): StorageSource {
44
51
 
45
- const storageBasePath = `${apiUrl}/api/storage`;
52
+ const storageBasePath = `${apiUrl.replace(/\/+$/, "")}${apiPath}/storage`;
46
53
 
47
54
  // Cache for download URLs to avoid redundant API calls
48
55
  const urlsCache = useMemo(() => new Map<string, DownloadConfig>(), []);
@@ -1,5 +1,5 @@
1
1
  import React, { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
2
- import { Blocker, BlockerFunction, useBlocker } from "react-router-dom";
2
+ import { Blocker, BlockerFunction, useBlocker } from "react-router";
3
3
 
4
4
  const IDLE_BLOCKER: Blocker = {
5
5
  state: "unblocked",
@@ -28,6 +28,32 @@ export interface StudioBridge {
28
28
  urlController: UrlController;
29
29
  navigationState: NavigationStateController;
30
30
  breadcrumbs: BreadcrumbsController;
31
+ capabilities: StudioCapabilities;
32
+ }
33
+
34
+ /**
35
+ * What the *host* of these tools can do, as opposed to what the backend can.
36
+ *
37
+ * Studio is mounted in two very different places. In a project's own admin
38
+ * panel it runs next to the collection source files and can edit them through
39
+ * the schema-editor routes. In the hosted console it runs against somebody
40
+ * else's deployed container: there is no source to edit — the container is
41
+ * rebuilt from the customer's repository on every deploy — and the routes that
42
+ * would edit it are not mounted at all, because the framework switches the
43
+ * schema editor off under `NODE_ENV=production`.
44
+ *
45
+ * Tools that would otherwise offer a write into the codebase read this to
46
+ * decide whether that write is even meaningful.
47
+ */
48
+ export interface StudioCapabilities {
49
+ /**
50
+ * Whether the host has the project's collection source at hand and can
51
+ * write to it.
52
+ *
53
+ * Defaults to `true`, which is what an admin panel running beside its own
54
+ * `collectionsDir` has always assumed.
55
+ */
56
+ codebase: boolean;
31
57
  }
32
58
 
33
59
  // ─── Noop defaults ──────────────────────────────────────────────────
@@ -71,12 +97,17 @@ const NOOP_BREADCRUMBS: BreadcrumbsController = {
71
97
  updateCount: () => {}
72
98
  };
73
99
 
100
+ const DEFAULT_CAPABILITIES: StudioCapabilities = {
101
+ codebase: true
102
+ };
103
+
74
104
  const NOOP_BRIDGE: StudioBridge = {
75
105
  collectionRegistry: NOOP_COLLECTION_REGISTRY,
76
106
  sidePanelController: NOOP_SIDE_PANEL,
77
107
  urlController: NOOP_URL_CONTROLLER,
78
108
  navigationState: NOOP_NAVIGATION_STATE,
79
- breadcrumbs: NOOP_BREADCRUMBS
109
+ breadcrumbs: NOOP_BREADCRUMBS,
110
+ capabilities: DEFAULT_CAPABILITIES
80
111
  };
81
112
 
82
113
  // ─── Context & Provider ─────────────────────────────────────────────
@@ -107,10 +138,22 @@ export function StudioBridgeProvider({
107
138
  value: Partial<StudioBridge>;
108
139
  children: React.ReactNode;
109
140
  }) {
141
+ // Keyed on the slices rather than on `value`: callers write the object
142
+ // inline (`value={{ collectionRegistry }}`), so a `[value]` dependency
143
+ // rebuilt the context on every render of the host and re-rendered every
144
+ // Studio consumer with it.
110
145
  const merged = React.useMemo(
111
146
  () => ({ ...NOOP_BRIDGE,
112
147
  ...value }),
113
- [value]
148
+ // eslint-disable-next-line react-hooks/exhaustive-deps
149
+ [
150
+ value.collectionRegistry,
151
+ value.sidePanelController,
152
+ value.urlController,
153
+ value.navigationState,
154
+ value.breadcrumbs,
155
+ value.capabilities
156
+ ]
114
157
  );
115
158
  return (
116
159
  <StudioBridgeContext.Provider value={merged}>
@@ -146,6 +189,11 @@ export function useStudioBreadcrumbs(): BreadcrumbsController {
146
189
  return useContext(StudioBridgeContext).breadcrumbs;
147
190
  }
148
191
 
192
+ /** What the host can do — see {@link StudioCapabilities}. */
193
+ export function useStudioCapabilities(): StudioCapabilities {
194
+ return useContext(StudioBridgeContext).capabilities;
195
+ }
196
+
149
197
  // ─── Self-Assembling Bridge Registry ────────────────────────────────
150
198
 
151
199
  /**
@@ -1,5 +1,5 @@
1
1
  import React, { useCallback, useEffect, useRef } from "react";
2
- import { useLocation } from "react-router-dom";
2
+ import { useLocation } from "react-router";
3
3
 
4
4
  const scrollsMap: Record<string, number> = {};
5
5