@rebasepro/app 0.9.1-canary.fd3754b → 0.10.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.
@@ -1,4 +1,13 @@
1
1
  import type { CollectionConfig, PropertyConfig } from "@rebasepro/types";
2
2
  import { AuthController } from "@rebasepro/types";
3
3
  export declare function getEntityPreviewKeys(authController: AuthController, targetCollection: CollectionConfig<any>, fields: Record<string, PropertyConfig>, previewProperties?: string[], limit?: number): string[];
4
+ /**
5
+ * The `include` params that eager-load a collection's relations in the same
6
+ * request as its rows, so previews never fetch once per relation cell.
7
+ *
8
+ * Only the REST transport reads `include`; the realtime transport embeds
9
+ * relation data unconditionally and ignores it. Passing it either way keeps a
10
+ * realtime-less deployment rendering the same cells as a realtime one.
11
+ */
12
+ export declare function getRelationIncludeParams(collection: CollectionConfig<any>): string[] | undefined;
4
13
  export declare function getEntityTitlePropertyKey<M extends Record<string, any>>(collection: CollectionConfig<M>, propertyConfigs: Record<string, PropertyConfig>): string | undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/app",
3
3
  "type": "module",
4
- "version": "0.9.1-canary.fd3754b",
4
+ "version": "0.10.0",
5
5
  "description": "Rebase core — framework-agnostic runtime for data-driven admin panels",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -13,12 +13,12 @@
13
13
  "url": "https://github.com/rebasepro/rebase.git",
14
14
  "directory": "packages/app"
15
15
  },
16
- "main": "./dist/index.umd.js",
16
+ "main": "./dist/index.es.js",
17
17
  "module": "./dist/index.es.js",
18
18
  "types": "./dist/index.d.ts",
19
19
  "source": "src/index.ts",
20
20
  "engines": {
21
- "node": ">=14"
21
+ "node": ">=20"
22
22
  },
23
23
  "keywords": [
24
24
  "rebase",
@@ -34,14 +34,12 @@
34
34
  ".": {
35
35
  "types": "./dist/index.d.ts",
36
36
  "development": "./dist/index.es.js",
37
- "import": "./dist/index.es.js",
38
- "require": "./dist/index.umd.js"
37
+ "import": "./dist/index.es.js"
39
38
  },
40
39
  "./vitePlugin": {
41
40
  "types": "./dist/vitePlugin.d.ts",
42
41
  "development": "./dist/vitePlugin.js",
43
- "import": "./dist/vitePlugin.js",
44
- "require": "./dist/vitePlugin.js"
42
+ "import": "./dist/vitePlugin.js"
45
43
  },
46
44
  "./package.json": "./package.json"
47
45
  },
@@ -54,11 +52,11 @@
54
52
  "magic-string": "^0.30.0",
55
53
  "notistack": "^3.0.2",
56
54
  "react-i18next": "^17.0.8",
57
- "@rebasepro/forms": "0.9.1-canary.fd3754b",
58
- "@rebasepro/common": "0.9.1-canary.fd3754b",
59
- "@rebasepro/types": "0.9.1-canary.fd3754b",
60
- "@rebasepro/ui": "0.9.1-canary.fd3754b",
61
- "@rebasepro/utils": "0.9.1-canary.fd3754b"
55
+ "@rebasepro/common": "0.10.0",
56
+ "@rebasepro/types": "0.10.0",
57
+ "@rebasepro/forms": "0.10.0",
58
+ "@rebasepro/ui": "0.10.0",
59
+ "@rebasepro/utils": "0.10.0"
62
60
  },
63
61
  "peerDependencies": {
64
62
  "react": ">=19.0.0",
@@ -69,6 +67,7 @@
69
67
  },
70
68
  "devDependencies": {
71
69
  "@jest/globals": "^30.4.1",
70
+ "@testing-library/jest-dom": "^6.9.1",
72
71
  "@testing-library/react": "^16.3.2",
73
72
  "@testing-library/user-event": "^14.6.1",
74
73
  "@types/jest": "^30.0.0",
@@ -118,25 +117,19 @@
118
117
  "testEnvironment": "jsdom",
119
118
  "testPathIgnorePatterns": [
120
119
  "/node_modules/",
121
- "/src/components/",
122
- "objects.test.ts",
123
- "navigation.test.ts",
124
- "resolutions.test.ts"
120
+ "/src/components/"
125
121
  ],
126
122
  "setupFilesAfterEnv": [
127
123
  "<rootDir>/test/setupTests.ts"
128
124
  ],
129
125
  "moduleNameMapper": {
130
126
  "\\.(css|less)$": "<rootDir>/test/__mocks__/styleMock.js",
131
- "^@rebasepro/ui$": "<rootDir>/../ui/src/index.ts",
132
- "^@rebasepro/common$": "<rootDir>/../common/src/index.ts",
133
- "^@rebasepro/types$": "<rootDir>/../types/src/index.ts",
134
- "^@rebasepro/forms$": "<rootDir>/../forms/src/index.ts"
127
+ "^@rebasepro/([a-z0-9-]+)$": "<rootDir>/../$1/src/index.ts"
135
128
  }
136
129
  },
137
130
  "scripts": {
138
131
  "watch": "vite build --watch",
139
- "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && pnpm exec esbuild src/vitePlugin.ts --platform=node --format=esm --outfile=dist/vitePlugin.js",
132
+ "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && pnpm exec esbuild src/vitePlugin.ts --platform=node --format=esm --outfile=dist/vitePlugin.js && node ../../scripts/assert-build-output.mjs",
140
133
  "test:lint": "eslint \"src/**\" --quiet",
141
134
  "test": "jest --passWithNoTests",
142
135
  "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f",
package/src/auth/index.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
- * @rebasepro/app
2
+ * Authentication controller for Rebase frontends.
3
3
  *
4
- * React authentication controller for Rebase frontends.
5
- * Provides the `useRebaseAuthController` hook and API utilities
6
- * for communicating with a Rebase backend's JWT auth endpoints.
4
+ * Provides the `useRebaseAuthController` hook and API utilities for
5
+ * communicating with a Rebase backend's JWT auth endpoints.
7
6
  *
8
- * For the generic LoginView and RebaseAuth components, see @rebasepro/app.
7
+ * The generic LoginView and RebaseAuth components that render on top of this
8
+ * controller are in `../components`. Both are re-exported from the package
9
+ * root, which is where applications should import them from.
9
10
  */
10
11
 
11
12
  // Types
@@ -628,15 +628,20 @@ function LoginForm({
628
628
  };
629
629
  }, [onClose]);
630
630
 
631
+ // Both controller methods record the failure in `authProviderError` — which
632
+ // this form renders above — and then rethrow for programmatic callers. This
633
+ // is not one of those: without a catch here every rejected sign-in becomes an
634
+ // unhandled rejection, reported as a crash by anything listening for one,
635
+ // while the user has already been shown the error.
631
636
  function handleEnterPassword() {
632
637
  if (email && password && authController.emailPasswordLogin) {
633
- authController.emailPasswordLogin(email, password);
638
+ void Promise.resolve(authController.emailPasswordLogin(email, password)).catch(() => undefined);
634
639
  }
635
640
  }
636
641
 
637
642
  function handleRegistration() {
638
643
  if (email && password && authController.register) {
639
- authController.register(email, password, displayName);
644
+ void Promise.resolve(authController.register(email, password, displayName)).catch(() => undefined);
640
645
  }
641
646
  }
642
647
 
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@rebasepro/ui";
3
- import { useTranslation } from "../hooks";
3
+ import { useTranslation } from "../hooks/useTranslation";
4
4
 
5
5
  export interface UnsavedChangesDialogProps {
6
6
  open: boolean;
@@ -5,6 +5,7 @@ import { useLocation } from "react-router-dom";
5
5
  import { useData, useRebaseContext } from "../../hooks";
6
6
  import { useDataOrder } from "../../hooks/data/useDataOrder";
7
7
  import { populateFetchCache } from "../../hooks/data/useFetch";
8
+ import { getRelationIncludeParams } from "../../util/previews";
8
9
  import { Entity, EntityReference, EntityRelation, EntityTableController, FilterValues, RebaseContext, SelectedCellProps, User, WhereFilterOp, FindResponse } from "@rebasepro/types";
9
10
  import { ScrollRestorationController } from "./useScrollRestoration";
10
11
 
@@ -254,19 +255,24 @@ export function useDataTableController<M extends Record<string, any> = any, USER
254
255
 
255
256
  let unsubscribe: (() => void) | undefined;
256
257
 
258
+ // Eagerly include relations to avoid N+1 fetches.
259
+ const includeParams = getRelationIncludeParams(collection);
260
+
257
261
  if (accessor.listen) {
258
262
  unsubscribe = accessor.listen({
259
263
  where: whereParams,
260
264
  limit: itemCount,
261
265
  orderBy: orderByParams,
262
- searchString
266
+ searchString,
267
+ include: includeParams
263
268
  }, (res) => onEntitiesUpdate(res.data as Entity<M>[]), onError);
264
269
  } else {
265
270
  accessor.find({
266
271
  where: whereParams,
267
272
  limit: itemCount,
268
273
  orderBy: orderByParams,
269
- searchString
274
+ searchString,
275
+ include: includeParams
270
276
  })
271
277
  .then((res) => onEntitiesUpdate(res.data as Entity<M>[]))
272
278
  .catch(onError);
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { AdminModeController } from "../hooks";
2
+ import type { AdminModeController } from "../hooks/useAdminModeController";
3
3
 
4
4
  const DEFAULT_ADMIN_MODE_STATE: AdminModeController = {
5
5
  mode: "content",
@@ -0,0 +1,24 @@
1
+ import React from "react";
2
+
3
+ /**
4
+ * Look up a collection's config by slug.
5
+ */
6
+ export type CollectionResolver = (slug: string) => { properties?: Record<string, unknown> } | undefined;
7
+
8
+ /**
9
+ * Lets the layer that owns the collections hand a resolver *up* to the data
10
+ * layer, which needs a collection's primary keys to give its rows an address.
11
+ *
12
+ * The inversion is forced by the composition: `<Rebase client>` builds the data
13
+ * layer, and `<RebaseAdmin collections>` sits inside it — so the collections are
14
+ * not in scope where the data is created, and `Rebase` cannot take them as a
15
+ * prop without giving every headless BaaS app a collections argument it has no
16
+ * use for.
17
+ *
18
+ * Registration is a ref write, so it is safe to call during render — and it has
19
+ * to be. The views that fetch rows are *below* the registrar, and child effects
20
+ * run before parent effects, so registering in an effect would bind after the
21
+ * first page of rows had already been converted.
22
+ */
23
+ export const CollectionResolverRegistrationContext =
24
+ React.createContext<(resolver: CollectionResolver | undefined) => void>(() => undefined);
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { ModeController } from "../hooks";
2
+ import type { ModeController } from "../hooks/useModeController";
3
3
 
4
4
  const DEFAULT_MODE_STATE: ModeController = {
5
5
  mode: "light",
@@ -15,3 +15,4 @@ export * from "./RebaseClientInstanceContext";
15
15
  export * from "./CustomizationControllerContext";
16
16
  export * from "./ComponentOverrideContext";
17
17
  export * from "./CollectionScopeContext";
18
+ export * from "./CollectionResolverContext";
@@ -8,7 +8,7 @@ import { CenteredView, Typography } from "@rebasepro/ui";
8
8
  import { RebaseContext, User, CollectionRegistryController, DataDriver, DataSourceDefinition, RebaseData, DEFAULT_DATA_SOURCE_KEY, StorageSource, StorageSourceDefinition, DEFAULT_STORAGE_SOURCE_KEY } from "@rebasepro/types";
9
9
  import { PluginProviderStack } from "./PluginProviderStack";
10
10
  import { PluginLifecycleManager } from "./PluginLifecycleManager";
11
- import { AuthControllerContext } from "../contexts";
11
+ import { AuthControllerContext, CollectionResolverRegistrationContext, CollectionResolver } from "../contexts";
12
12
  import { useCustomizationController, useRebaseContext, useAuthSubscription } from "../hooks";
13
13
  import { ApiConfigProvider, useApiConfig } from "../hooks/ApiConfigContext";
14
14
  import { ErrorView } from "../components";
@@ -98,6 +98,18 @@ export function Rebase<USER extends User>(props: RebaseProps<USER>) {
98
98
  return dataSourcesProp ?? [];
99
99
  }, [dataSourcesProp]);
100
100
 
101
+ // Rows arrive as columns only; their address is derived from the
102
+ // collection's primary keys. The collections live below us (in RebaseAdmin),
103
+ // so they register a resolver here and we read it lazily, per row. Headless
104
+ // apps register nothing and are unaffected.
105
+ const collectionResolverRef = useRef<CollectionResolver | undefined>(undefined);
106
+ const registerCollectionResolver = React.useCallback((resolver: CollectionResolver | undefined) => {
107
+ collectionResolverRef.current = resolver;
108
+ }, []);
109
+ const entityDataOptions = useMemo(() => ({
110
+ resolveCollection: (slug: string) => collectionResolverRef.current?.(slug)
111
+ }), []);
112
+
101
113
  // Build the data-source context: the declared registry plus a RebaseData
102
114
  // per direct/custom source (those carrying a client-side driver). Server-
103
115
  // mediated sources have no entry — they ride the default client.
@@ -131,12 +143,12 @@ export function Rebase<USER extends User>(props: RebaseProps<USER>) {
131
143
  ...definition,
132
144
  transport: ds.transport ?? (driver ? "direct" : "server")
133
145
  };
134
- if (driver) sources[ds.key] = buildRebaseData(driver);
146
+ if (driver) sources[ds.key] = buildRebaseData(driver, entityDataOptions);
135
147
  }
136
148
  const value: DataSourcesContextValue = { registry, sources };
137
149
  dataSourcesRef.current = { sig, value };
138
150
  return value;
139
- }, [normalizedDataSources]);
151
+ }, [normalizedDataSources, entityDataOptions]);
140
152
 
141
153
  // Default data source — serves every collection not routed to a
142
154
  // registered direct/custom source. Resolution:
@@ -148,7 +160,7 @@ export function Rebase<USER extends User>(props: RebaseProps<USER>) {
148
160
  if (registeredDefault) return registeredDefault;
149
161
  // CMS boundary: the SDK client returns flat rows; wrap them into the
150
162
  // Entity view-model the admin (`useData()`) renders.
151
- if (client?.data) return wrapAsEntityData(client.data);
163
+ if (client?.data) return wrapAsEntityData(client.data, entityDataOptions);
152
164
  const built = Object.values(dataSourcesValue.sources);
153
165
  if (built.length === 1) return built[0];
154
166
  if (built.length > 1) {
@@ -158,7 +170,7 @@ export function Rebase<USER extends User>(props: RebaseProps<USER>) {
158
170
  );
159
171
  }
160
172
  throw new Error("Rebase requires either `client` or a `dataSources` entry with a driver to be provided");
161
- }, [client, dataSourcesValue]);
173
+ }, [client, dataSourcesValue, entityDataOptions]);
162
174
 
163
175
  // Storage fallback logic
164
176
  const resolvedStorage = storageSourceProp ?? client?.storage;
@@ -276,6 +288,7 @@ export function Rebase<USER extends User>(props: RebaseProps<USER>) {
276
288
  executeSql: wsAdmin.executeSql!.bind(wsAdmin),
277
289
  fetchAvailableDatabases: wsAdmin.fetchAvailableDatabases?.bind(wsAdmin),
278
290
  fetchAvailableRoles: wsAdmin.fetchAvailableRoles?.bind(wsAdmin),
291
+ fetchApplicationRoles: wsAdmin.fetchApplicationRoles?.bind(wsAdmin),
279
292
  fetchCurrentDatabase: wsAdmin.fetchCurrentDatabase?.bind(wsAdmin),
280
293
  fetchUnmappedTables: wsAdmin.fetchUnmappedTables?.bind(wsAdmin),
281
294
  fetchTableMetadata: wsAdmin.fetchTableMetadata?.bind(wsAdmin),
@@ -349,6 +362,8 @@ export function Rebase<USER extends User>(props: RebaseProps<USER>) {
349
362
  value={storageSourcesValue}>
350
363
  <StorageSourceContext.Provider
351
364
  value={resolvedStorage!}>
365
+ <CollectionResolverRegistrationContext.Provider
366
+ value={registerCollectionResolver}>
352
367
  <DataSourcesContext.Provider
353
368
  value={dataSourcesValue}>
354
369
  <RebaseDataContext.Provider
@@ -373,6 +388,7 @@ export function Rebase<USER extends User>(props: RebaseProps<USER>) {
373
388
  </DatabaseAdminContext.Provider>
374
389
  </RebaseDataContext.Provider>
375
390
  </DataSourcesContext.Provider>
391
+ </CollectionResolverRegistrationContext.Provider>
376
392
  </StorageSourceContext.Provider>
377
393
  </StorageSourcesContext.Provider>
378
394
  </UserConfigurationPersistenceContext.Provider>
@@ -3,6 +3,7 @@ import { useEffect, useState, useMemo } from "react";
3
3
  import { Entity, FilterValues, User } from "@rebasepro/types";
4
4
  import { useData } from "./useData";
5
5
  import { isSchemaDriftError, useSchemaDriftContext } from "../../components/SchemaDriftBanner";
6
+ import { getRelationIncludeParams } from "../../util/previews";
6
7
  /**
7
8
  * @group Hooks and utilities
8
9
  */
@@ -131,10 +132,7 @@ export function useCollection<M extends Record<string, any>, USER extends User>(
131
132
  const accessor = dataClient.collection(path);
132
133
 
133
134
  // Eagerly include relations to avoid N+1 fetches.
134
- const hasRelations = collection.properties && Object.values(collection.properties).some(
135
- (p) => p.type === "relation" || p.type === "reference"
136
- );
137
- const includeParams = hasRelations ? ["*"] : undefined;
135
+ const includeParams = getRelationIncludeParams(collection);
138
136
 
139
137
  if (accessor.listen) {
140
138
  return accessor.listen({
@@ -2,6 +2,7 @@ import type { CollectionConfig } from "@rebasepro/types";
2
2
  import { useCallback, useEffect, useRef, useState, useMemo } from "react";
3
3
  import { useData } from "./useData";
4
4
  import { Entity, EntityRelation, FilterValues } from "@rebasepro/types";
5
+ import { getRelationIncludeParams } from "../../util/previews";
5
6
  export interface RelationItem {
6
7
  id: string | number;
7
8
  label: string;
@@ -133,6 +134,10 @@ export function useRelationSelector<M extends Record<string, any> = any>(
133
134
  }
134
135
  }, []);
135
136
 
137
+ // Eagerly include relations to avoid N+1 fetches. Referentially stable, so
138
+ // it can be a dependency below without re-triggering the fetch.
139
+ const includeParams = getRelationIncludeParams(collection);
140
+
136
141
  const fetchData = useCallback(() => {
137
142
  cleanupSubscription();
138
143
  setError(undefined);
@@ -163,7 +168,8 @@ export function useRelationSelector<M extends Record<string, any> = any>(
163
168
  where: whereParams,
164
169
  limit: limit,
165
170
  orderBy: undefined,
166
- searchString: currentSearch
171
+ searchString: currentSearch,
172
+ include: includeParams
167
173
  }, (res) => onEntitiesUpdate({ data: res.data as Entity<M>[],
168
174
  meta: res.meta }), onErrorUpdate);
169
175
  } else {
@@ -172,7 +178,8 @@ meta: res.meta }), onErrorUpdate);
172
178
  limit: limit,
173
179
  offset: 0,
174
180
  orderBy: undefined,
175
- searchString: currentSearch
181
+ searchString: currentSearch,
182
+ include: includeParams
176
183
  })
177
184
  .then((res) => onEntitiesUpdate({ data: res.data as Entity<M>[],
178
185
  meta: res.meta }))
@@ -181,7 +188,7 @@ meta: res.meta }))
181
188
  }
182
189
 
183
190
  unsubscribeRef.current = unsubscribe || null;
184
- }, [dataClient, path, fixedFilter, limit, currentSearch, entityToRelationItem, cleanupSubscription, setLoading]);
191
+ }, [dataClient, path, fixedFilter, limit, currentSearch, entityToRelationItem, cleanupSubscription, setLoading, includeParams]);
185
192
 
186
193
  // Search function with debouncing
187
194
  const search = useCallback((searchString: string) => {
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useMemo, useState } from "react";
2
- import { AdminModeController } from "./index";
2
+ import type { AdminModeController } from "./useAdminModeController";
3
3
 
4
4
  /**
5
5
  * Use this hook to build an admin mode controller that determines
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useState, useMemo } from "react";
2
2
 
3
- import { ModeController } from "./index";
3
+ import type { ModeController } from "./useModeController";
4
4
 
5
5
  /**
6
6
  * Use this hook to build a color mode controller that determines
@@ -0,0 +1,108 @@
1
+ import React, { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
2
+ import { Blocker, BlockerFunction, useBlocker } from "react-router-dom";
3
+
4
+ const IDLE_BLOCKER: Blocker = {
5
+ state: "unblocked",
6
+ proceed: undefined,
7
+ reset: undefined,
8
+ location: undefined
9
+ };
10
+
11
+ type NavigationBlockerContextValue = {
12
+ register: (id: string, predicate: BlockerFunction) => void;
13
+ unregister: (id: string) => void;
14
+ blocker: Blocker;
15
+ /** Id of the registration that caused the current block, if any. */
16
+ blockedBy: string | null;
17
+ };
18
+
19
+ const NavigationBlockerContext = createContext<NavigationBlockerContextValue | null>(null);
20
+
21
+ /**
22
+ * Owns the single React Router blocker for the whole app.
23
+ *
24
+ * React Router only honours **one** blocker at a time: `shouldBlockNavigation`
25
+ * picks the last-registered blocker function and silently ignores every other
26
+ * one (it only logs a dev warning). Because blockers register in a `useEffect`,
27
+ * the winner is whichever surface mounted most recently — so an unsaved-changes
28
+ * guard could be disabled by an unrelated component mounting after it.
29
+ *
30
+ * This provider registers the only `useBlocker` in the tree and multiplexes it,
31
+ * so every surface that needs to guard navigation gets a say regardless of
32
+ * mount order. Surfaces register through {@link useNavigationBlocker}.
33
+ */
34
+ export function NavigationBlockerProvider({ children }: { children: React.ReactNode }) {
35
+
36
+ const predicates = useRef(new Map<string, BlockerFunction>());
37
+
38
+ // State, not a ref: the owning registration only renders its dialog once
39
+ // this has propagated, so it has to go through React.
40
+ const [blockedBy, setBlockedBy] = useState<string | null>(null);
41
+
42
+ const shouldBlock = useCallback<BlockerFunction>((args) => {
43
+ for (const [id, predicate] of predicates.current) {
44
+ if (predicate(args)) {
45
+ setBlockedBy(id);
46
+ return true;
47
+ }
48
+ }
49
+ setBlockedBy(null);
50
+ return false;
51
+ }, []);
52
+
53
+ const blocker = useBlocker(shouldBlock);
54
+
55
+ const register = useCallback((id: string, predicate: BlockerFunction) => {
56
+ predicates.current.set(id, predicate);
57
+ }, []);
58
+
59
+ const unregister = useCallback((id: string) => {
60
+ predicates.current.delete(id);
61
+ setBlockedBy((current) => (current === id ? null : current));
62
+ }, []);
63
+
64
+ const value = useMemo<NavigationBlockerContextValue>(() => ({
65
+ register,
66
+ unregister,
67
+ blocker,
68
+ blockedBy
69
+ }), [register, unregister, blocker, blockedBy]);
70
+
71
+ return (
72
+ <NavigationBlockerContext.Provider value={value}>
73
+ {children}
74
+ </NavigationBlockerContext.Provider>
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Guard navigation with `predicate`, sharing the app-wide blocker.
80
+ *
81
+ * Returns a {@link Blocker} that is only ever in the `blocked` state when *this*
82
+ * registration is what blocked the navigation — so several guards can coexist
83
+ * without each of them popping a dialog.
84
+ *
85
+ * Returns an idle blocker when no {@link NavigationBlockerProvider} is mounted
86
+ * above, rather than competing for React Router's single blocker slot.
87
+ */
88
+ export function useNavigationBlocker(predicate: BlockerFunction): Blocker {
89
+
90
+ const context = useContext(NavigationBlockerContext);
91
+ const id = useId();
92
+
93
+ // Keep the latest predicate without re-registering on every render.
94
+ const predicateRef = useRef(predicate);
95
+ predicateRef.current = predicate;
96
+
97
+ const register = context?.register;
98
+ const unregister = context?.unregister;
99
+
100
+ useEffect(() => {
101
+ if (!register || !unregister) return;
102
+ register(id, (args) => predicateRef.current(args));
103
+ return () => unregister(id);
104
+ }, [register, unregister, id]);
105
+
106
+ if (!context) return IDLE_BLOCKER;
107
+ return context.blockedBy === id ? context.blocker : IDLE_BLOCKER;
108
+ }
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useState, useMemo } from "react";
2
- import { useBlocker } from "react-router-dom";
3
2
  import { UnsavedChangesDialogProps } from "../components/UnsavedChangesDialog";
3
+ import { useNavigationBlocker } from "./useNavigationBlocker";
4
4
 
5
5
  /**
6
6
  * A single, unified hook to prevent navigation when there are unsaved changes.
@@ -18,9 +18,12 @@ export function useUnsavedChangesDialog(
18
18
  } {
19
19
  const [manualDialogOpen, setManualDialogOpen] = useState(false);
20
20
 
21
- const blocker = useBlocker(
22
- ({ currentLocation, nextLocation }) =>
23
- when && currentLocation.pathname !== nextLocation.pathname
21
+ const blocker = useNavigationBlocker(
22
+ useCallback(
23
+ ({ currentLocation, nextLocation }) =>
24
+ when && currentLocation.pathname !== nextLocation.pathname,
25
+ [when]
26
+ )
24
27
  );
25
28
 
26
29
  useEffect(() => {
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export * from "./contexts";
12
12
  export { CONTAINER_FULL_WIDTH, ADDITIONAL_TAB_WIDTH, FORM_CONTAINER_WIDTH } from "./internal/common";
13
13
  export { useRestoreScroll } from "./internal/useRestoreScroll";
14
14
  export { useUnsavedChangesDialog } from "./hooks/useUnsavedChangesDialog";
15
+ export { NavigationBlockerProvider, useNavigationBlocker } from "./hooks/useNavigationBlocker";
15
16
  export type { UnsavedChangesDialogProps } from "./components/UnsavedChangesDialog";
16
17
  export { UnsavedChangesDialog } from "./components/UnsavedChangesDialog";
17
18
  export * from "./i18n/RebaseI18nProvider";
@@ -74,6 +74,26 @@ export function getEntityPreviewKeys(
74
74
  }
75
75
  }
76
76
 
77
+ // Returned as-is rather than rebuilt per call, so the result is referentially
78
+ // stable and safe to use as a hook dependency.
79
+ const INCLUDE_ALL_RELATIONS: string[] = ["*"];
80
+
81
+ /**
82
+ * The `include` params that eager-load a collection's relations in the same
83
+ * request as its rows, so previews never fetch once per relation cell.
84
+ *
85
+ * Only the REST transport reads `include`; the realtime transport embeds
86
+ * relation data unconditionally and ignores it. Passing it either way keeps a
87
+ * realtime-less deployment rendering the same cells as a realtime one.
88
+ */
89
+ export function getRelationIncludeParams(collection: CollectionConfig<any>): string[] | undefined {
90
+ if (!collection.properties) return undefined;
91
+ const hasRelations = Object.values(collection.properties).some(property =>
92
+ property && !isPropertyBuilder(property) &&
93
+ (isRelationProperty(property as Property) || isReferenceProperty(property as Property)));
94
+ return hasRelations ? INCLUDE_ALL_RELATIONS : undefined;
95
+ }
96
+
77
97
  export function getEntityTitlePropertyKey<M extends Record<string, any>>(collection: CollectionConfig<M>, propertyConfigs: Record<string, PropertyConfig>): string | undefined {
78
98
  if (collection.titleProperty) {
79
99
  return collection.titleProperty as string;