@firecms/core 3.4.0-canary.0ef7442 → 3.4.0-canary.1faa2a9

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 (152) hide show
  1. package/README.md +4 -0
  2. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +3 -2
  3. package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
  4. package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
  5. package/dist/components/ErrorBoundary.d.ts +1 -3
  6. package/dist/components/LicenseBanner.d.ts +23 -0
  7. package/dist/components/common/useDataSourceTableController.d.ts +4 -2
  8. package/dist/components/common/useTableSearchHelper.d.ts +3 -1
  9. package/dist/components/index.d.ts +1 -0
  10. package/dist/contexts/LicenseStatusContext.d.ts +16 -0
  11. package/dist/core/EntityEditView.d.ts +2 -0
  12. package/dist/core/EntityEditViewFormActions.d.ts +1 -1
  13. package/dist/core/index.d.ts +1 -0
  14. package/dist/core/plugin_translations.d.ts +38 -0
  15. package/dist/core/pro_plugins.d.ts +40 -0
  16. package/dist/de-KtBROUvS.js +700 -0
  17. package/dist/de-KtBROUvS.js.map +1 -0
  18. package/dist/editor/extensions/Image.d.ts +11 -0
  19. package/dist/editor/index.d.ts +2 -1
  20. package/dist/editor/lazy_editor.d.ts +6 -0
  21. package/dist/editor-BSo_aS1I.js +4563 -0
  22. package/dist/editor-BSo_aS1I.js.map +1 -0
  23. package/dist/form/EntityForm.d.ts +2 -0
  24. package/dist/form/EntityFormActions.d.ts +2 -0
  25. package/dist/fr-BRFSBaVh.js +700 -0
  26. package/dist/fr-BRFSBaVh.js.map +1 -0
  27. package/dist/hi-DwCkga5X.js +700 -0
  28. package/dist/hi-DwCkga5X.js.map +1 -0
  29. package/dist/hooks/data/delete.d.ts +1 -1
  30. package/dist/hooks/data/useCollectionFetch.d.ts +1 -1
  31. package/dist/hooks/data/useEntityFetch.d.ts +4 -1
  32. package/dist/hooks/index.d.ts +2 -0
  33. package/dist/hooks/useLicenseStatus.d.ts +16 -0
  34. package/dist/hooks/useProjectLog.d.ts +11 -6
  35. package/dist/hooks/useTranslation.d.ts +1 -1
  36. package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
  37. package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
  38. package/dist/index.es.js +3503 -11244
  39. package/dist/index.es.js.map +1 -1
  40. package/dist/index.umd.js +8028 -7006
  41. package/dist/index.umd.js.map +1 -1
  42. package/dist/it-B2YGgDsx.js +700 -0
  43. package/dist/it-B2YGgDsx.js.map +1 -0
  44. package/dist/pl-BxHkywK8.js +702 -0
  45. package/dist/pl-BxHkywK8.js.map +1 -0
  46. package/dist/pt-q6UV4diT.js +702 -0
  47. package/dist/pt-q6UV4diT.js.map +1 -0
  48. package/dist/types/analytics.d.ts +1 -1
  49. package/dist/types/collections.d.ts +7 -0
  50. package/dist/types/datasource.d.ts +8 -0
  51. package/dist/types/entity_actions.d.ts +6 -0
  52. package/dist/types/entity_callbacks.d.ts +21 -0
  53. package/dist/types/firecms.d.ts +18 -1
  54. package/dist/types/firecms_context.d.ts +8 -0
  55. package/dist/types/index.d.ts +1 -0
  56. package/dist/types/license.d.ts +54 -0
  57. package/dist/types/navigation.d.ts +34 -5
  58. package/dist/types/plugins.d.ts +22 -1
  59. package/dist/types/side_entity_controller.d.ts +3 -2
  60. package/dist/types/translations.d.ts +31 -2
  61. package/dist/util/date_fns_locales.d.ts +18 -0
  62. package/dist/util/entity_cache.d.ts +11 -0
  63. package/dist/util/index.d.ts +1 -0
  64. package/dist/util/lazy_eager.d.ts +3 -3
  65. package/dist/util/navigation_utils.d.ts +84 -2
  66. package/dist/util/parent_references_from_path.d.ts +15 -0
  67. package/dist/util/paths.d.ts +28 -0
  68. package/dist/util/permissions.d.ts +10 -4
  69. package/package.json +3 -3
  70. package/src/app/Scaffold.tsx +7 -0
  71. package/src/components/DeleteEntityDialog.tsx +2 -0
  72. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
  73. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +8 -0
  74. package/src/components/EntityCollectionView/EntityCollectionView.tsx +40 -15
  75. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
  76. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -0
  77. package/src/components/EntityCollectionView/useBoardDataController.tsx +7 -2
  78. package/src/components/EntityPreview.tsx +4 -1
  79. package/src/components/ErrorBoundary.tsx +12 -18
  80. package/src/components/LicenseBanner.tsx +209 -0
  81. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +8 -2
  82. package/src/components/common/default_entity_actions.tsx +22 -2
  83. package/src/components/common/useDataSourceTableController.tsx +26 -8
  84. package/src/components/common/useTableSearchHelper.ts +5 -0
  85. package/src/components/index.tsx +1 -0
  86. package/src/contexts/LicenseStatusContext.tsx +50 -0
  87. package/src/core/DefaultAppBar.tsx +28 -16
  88. package/src/core/EntityEditView.tsx +8 -12
  89. package/src/core/EntityEditViewFormActions.tsx +3 -2
  90. package/src/core/EntitySidePanel.tsx +6 -4
  91. package/src/core/FireCMS.tsx +73 -47
  92. package/src/core/SideDialogs.tsx +44 -1
  93. package/src/core/index.tsx +1 -0
  94. package/src/core/plugin_translations.ts +87 -0
  95. package/src/core/pro_plugins.ts +79 -0
  96. package/src/editor/components/SlashCommandMenu.tsx +4 -1
  97. package/src/editor/extensions/Image.ts +17 -1
  98. package/src/editor/hooks/useProseMirror.ts +5 -0
  99. package/src/editor/index.ts +11 -1
  100. package/src/editor/lazy_editor.tsx +33 -0
  101. package/src/form/EntityForm.tsx +22 -7
  102. package/src/form/EntityFormActions.tsx +2 -0
  103. package/src/hooks/data/delete.ts +8 -2
  104. package/src/hooks/data/save.ts +4 -1
  105. package/src/hooks/data/useCollectionFetch.tsx +9 -2
  106. package/src/hooks/data/useEntityFetch.tsx +19 -6
  107. package/src/hooks/index.tsx +2 -0
  108. package/src/hooks/useBuildNavigationController.tsx +70 -15
  109. package/src/hooks/useFireCMSContext.tsx +7 -3
  110. package/src/hooks/useLicenseStatus.tsx +32 -0
  111. package/src/hooks/useProjectLog.tsx +27 -9
  112. package/src/hooks/useResolvedNavigationFrom.tsx +1 -1
  113. package/src/hooks/useTranslation.ts +3 -1
  114. package/src/i18n/FireCMSi18nProvider.tsx +199 -25
  115. package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
  116. package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
  117. package/src/internal/useBuildDataSource.ts +13 -12
  118. package/src/internal/useBuildSideEntityController.tsx +13 -5
  119. package/src/locales/de.ts +16 -2
  120. package/src/locales/en.ts +18 -2
  121. package/src/locales/es.ts +16 -2
  122. package/src/locales/fr.ts +16 -2
  123. package/src/locales/hi.ts +16 -2
  124. package/src/locales/it.ts +16 -2
  125. package/src/locales/pl.ts +16 -2
  126. package/src/locales/pt.ts +16 -2
  127. package/src/preview/components/DatePreview.tsx +2 -4
  128. package/src/preview/components/ReferencePreview.tsx +7 -3
  129. package/src/routes/FireCMSRoute.tsx +9 -4
  130. package/src/types/analytics.ts +3 -0
  131. package/src/types/collections.ts +8 -0
  132. package/src/types/datasource.ts +8 -0
  133. package/src/types/entity_actions.tsx +6 -0
  134. package/src/types/entity_callbacks.ts +24 -0
  135. package/src/types/firecms.tsx +19 -1
  136. package/src/types/firecms_context.tsx +9 -0
  137. package/src/types/index.ts +1 -0
  138. package/src/types/license.ts +62 -0
  139. package/src/types/navigation.ts +35 -5
  140. package/src/types/plugins.tsx +23 -1
  141. package/src/types/side_entity_controller.tsx +3 -2
  142. package/src/types/translations.ts +31 -2
  143. package/src/util/date_fns_locales.ts +168 -0
  144. package/src/util/entity_cache.ts +18 -0
  145. package/src/util/index.ts +1 -0
  146. package/src/util/lazy_eager.tsx +30 -8
  147. package/src/util/navigation_from_path.ts +6 -1
  148. package/src/util/navigation_utils.ts +204 -2
  149. package/src/util/parent_references_from_path.ts +32 -1
  150. package/src/util/paths.ts +40 -3
  151. package/src/util/permissions.ts +22 -10
  152. package/src/util/useStorageUploadController.tsx +6 -1
@@ -11,6 +11,7 @@ import { DialogsController } from "./dialogs_controller";
11
11
  import { CustomizationController } from "./customization_controller";
12
12
  import { AnalyticsController } from "./analytics_controller";
13
13
  import { InternalUserManagement } from "./internal_user_management";
14
+ import { AccessResponse } from "./license";
14
15
 
15
16
  /**
16
17
  * Context that includes the internal controllers and contexts used by the app.
@@ -97,4 +98,12 @@ export type FireCMSContext<USER extends User = User, AuthControllerType extends
97
98
  */
98
99
  userManagement: InternalUserManagement<USER>
99
100
 
101
+ /**
102
+ * FireCMS license status of this project, `null` until the license check
103
+ * answers or when it is not sent. PRO plugins are paused while `blocked`
104
+ * is true.
105
+ * @see useLicenseStatus
106
+ */
107
+ licenseStatus?: AccessResponse | null;
108
+
100
109
  };
@@ -29,3 +29,4 @@ export * from "./modify_collections";
29
29
  export * from "./analytics_controller";
30
30
  export * from "./customization_controller";
31
31
  export * from "./translations";
32
+ export * from "./license";
@@ -0,0 +1,62 @@
1
+ /**
2
+ * What the FireCMS license check says about this project.
3
+ *
4
+ * - `not_required`: no PRO plugin is mounted.
5
+ * - `whitelisted`: the project is exempt from licensing.
6
+ * - `licensed`: a license covers this project.
7
+ * - `trial`: PRO plugins run on the free trial; see `daysLeft` and `trialEndsAt`.
8
+ * - `expired`: the trial ended without a license. PRO plugins are paused.
9
+ * - `invalid_project`: a license key was presented, but this project is not
10
+ * linked to that license. PRO plugins are paused.
11
+ * - `over_quota`: the license has more projects linked than it pays for.
12
+ * Nothing is paused.
13
+ *
14
+ * @group Models
15
+ */
16
+ export type LicenseState =
17
+ | "not_required"
18
+ | "whitelisted"
19
+ | "licensed"
20
+ | "trial"
21
+ | "expired"
22
+ | "invalid_project"
23
+ | "over_quota";
24
+
25
+ /**
26
+ * Response of the FireCMS license check (`POST https://api.firecms.co/access_log`).
27
+ *
28
+ * Servers older than the PRO relaunch send only `blocked` and `message`, so
29
+ * treat a missing `licenseState` as unknown. `blocked` is honoured either way.
30
+ *
31
+ * @group Models
32
+ */
33
+ export type AccessResponse = {
34
+ /**
35
+ * True when the PRO plugins must be paused. The core CMS is never blocked.
36
+ */
37
+ blocked?: boolean;
38
+ licenseState?: LicenseState;
39
+ /**
40
+ * Number of projects the license pays for. Present with `licensed` and `over_quota`.
41
+ */
42
+ licensedProjects?: number;
43
+ /**
44
+ * Number of projects linked to the license.
45
+ */
46
+ linkedProjects?: number;
47
+ projectId?: string;
48
+ licenseId?: string;
49
+ /**
50
+ * ISO date the trial ends or ended. Present with `trial` and `expired`.
51
+ */
52
+ trialEndsAt?: string;
53
+ /**
54
+ * Whole days left in the trial, present with `trial`.
55
+ */
56
+ daysLeft?: number;
57
+ /**
58
+ * Where to get or update a license for this project.
59
+ */
60
+ subscribeUrl?: string;
61
+ message?: string;
62
+ };
@@ -57,8 +57,12 @@ export type NavigationController<EC extends EntityCollection = EntityCollection<
57
57
  /**
58
58
  * Get the collection configuration for a given path.
59
59
  * The collection is resolved from the given path or alias.
60
+ *
61
+ * Pass `pathSegments` when you have them: without them the path is split on "/" and
62
+ * asserted to have an odd number of parts, which a chain containing a slash-bearing
63
+ * entity id fails — it throws instead of resolving.
60
64
  */
61
- getCollection: (pathOrId: string, includeUserOverride?: boolean) => EC | undefined;
65
+ getCollection: (pathOrId: string, includeUserOverride?: boolean, pathSegments?: string[]) => EC | undefined;
62
66
 
63
67
  /**
64
68
  * Get the top level collection configuration for a given id
@@ -118,9 +122,30 @@ export type NavigationController<EC extends EntityCollection = EntityCollection<
118
122
  /**
119
123
  * Turn a path with collection ids into a resolved path.
120
124
  * The ids (typically used in urls) will be replaced with relative paths (typically used in database paths)
125
+ *
126
+ * Pass `pathSegments` whenever you have them. Without them this has to find the entity
127
+ * ids inside the flattened string by reading up to the next "/", which is wrong for any
128
+ * id that contains one — the chain then shifts by a segment and resolution fails.
129
+ *
121
130
  * @param pathWithAliases
131
+ * @param pathSegments `pathWithAliases` split at its real segment boundaries, if known
122
132
  */
123
- resolveIdsFrom: (pathWithAliases: string) => string;
133
+ resolveIdsFrom: (pathWithAliases: string, pathSegments?: string[]) => string;
134
+
135
+ /**
136
+ * The segment-wise counterpart of {@link resolveIdsFrom}: replaces collection ids with
137
+ * their real paths while carrying every entity id through whole, slashes included.
138
+ *
139
+ * Idempotent — segments that are already resolved pass through unchanged.
140
+ *
141
+ * Optional so that a `navigationController` built against an earlier version keeps
142
+ * working: callers fall back to using the segments as given, which is what happened
143
+ * before this method existed. Controllers built by `useBuildNavigationController`
144
+ * always provide it.
145
+ *
146
+ * @param pathSegments
147
+ */
148
+ resolveSegmentsFrom?: (pathSegments: string[]) => string[];
124
149
 
125
150
  /**
126
151
  * Call this method to recalculate the navigation
@@ -128,16 +153,21 @@ export type NavigationController<EC extends EntityCollection = EntityCollection<
128
153
  refreshNavigation: () => void;
129
154
 
130
155
  /**
131
- * Retrieve all the related parent references for a given path
156
+ * Retrieve all the related parent references for a given path.
157
+ *
158
+ * `path` is expected escaped, as it comes from the URL. Pass `pathSegments` when the
159
+ * path at hand is a raw datasource path, whose ids may contain a bare "/".
160
+ *
132
161
  * @param path
162
+ * @param pathSegments `path` split at its real segment boundaries, with raw ids
133
163
  */
134
- getParentReferencesFromPath: (path: string) => EntityReference[];
164
+ getParentReferencesFromPath: (path: string, pathSegments?: string[]) => EntityReference[];
135
165
 
136
166
  /**
137
167
  * Retrieve all the related parent collection ids for a given path
138
168
  * @param path
139
169
  */
140
- getParentCollectionIds: (path: string) => string[];
170
+ getParentCollectionIds: (path: string, pathSegments?: string[]) => string[];
141
171
 
142
172
  /**
143
173
  * Resolve paths from a list of ids
@@ -15,6 +15,9 @@ import { InternalUserManagement } from "./internal_user_management";
15
15
  * NOTE: This is a work in progress and the API is not stable yet.
16
16
  * @group Core
17
17
  */
18
+ /** The strings a plugin contributes for one locale. */
19
+ export type PluginTranslations = Record<string, string>;
20
+
18
21
  export type FireCMSPlugin<PROPS = any, FORM_PROPS = any, EC extends EntityCollection = EntityCollection, COL_ACTIONS_PROPS = any, COL_ACTIONS_START__PROPS = any> = {
19
22
 
20
23
  /**
@@ -33,8 +36,14 @@ export type FireCMSPlugin<PROPS = any, FORM_PROPS = any, EC extends EntityCollec
33
36
  * locales, specific for this plugin.
34
37
  * Keys can be existing `FireCMSTranslations` keys, or new keys if using
35
38
  * module augmentation.
39
+ *
40
+ * A value may be the strings themselves, or a function returning a promise
41
+ * of them. Use the function form for locales you ship in the plugin bundle:
42
+ * only the active language is ever read, so importing all of them statically
43
+ * puts every language into the bundle of every app that mounts the plugin.
44
+ * Both forms are supported; a plain record keeps working unchanged.
36
45
  */
37
- i18n?: Record<string, Record<string, string>>;
46
+ i18n?: Record<string, PluginTranslations | (() => Promise<PluginTranslations>)>;
38
47
 
39
48
  /**
40
49
  * You can use this prop to add higher order components to the CMS.
@@ -146,6 +155,8 @@ export type FireCMSPlugin<PROPS = any, FORM_PROPS = any, EC extends EntityCollec
146
155
  blockSearch?: (props: {
147
156
  context: FireCMSContext,
148
157
  path: string,
158
+ /** `path` split at its real segment boundaries, when known. */
159
+ pathSegments?: string[],
149
160
  collection: EC,
150
161
  parentCollectionIds?: string[]
151
162
  }) => boolean;
@@ -153,6 +164,8 @@ export type FireCMSPlugin<PROPS = any, FORM_PROPS = any, EC extends EntityCollec
153
164
  showTextSearchBar?: (props: {
154
165
  context: FireCMSContext,
155
166
  path: string,
167
+ /** `path` split at its real segment boundaries, when known. */
168
+ pathSegments?: string[],
156
169
  collection: EC,
157
170
  parentCollectionIds?: string[]
158
171
  }) => boolean;
@@ -160,6 +173,8 @@ export type FireCMSPlugin<PROPS = any, FORM_PROPS = any, EC extends EntityCollec
160
173
  onTextSearchClick?: (props: {
161
174
  context: FireCMSContext,
162
175
  path: string,
176
+ /** `path` split at its real segment boundaries, when known. */
177
+ pathSegments?: string[],
163
178
  collection: EC,
164
179
  parentCollectionIds?: string[]
165
180
  }) => Promise<boolean>;
@@ -308,6 +323,13 @@ export interface PluginHomePageActionsProps<EP extends object = object, M extend
308
323
  export interface PluginFormActionProps<USER extends User = User, EC extends EntityCollection = EntityCollection> {
309
324
  entityId?: string;
310
325
  path: string;
326
+ /**
327
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
328
+ *
329
+ * Optional, and never derived by splitting `path` — a parent entity id may itself
330
+ * contain "/". Absent means "not known here", not "no slashes".
331
+ */
332
+ pathSegments?: string[];
311
333
  parentCollectionIds: string[];
312
334
  status: EntityStatus;
313
335
  collection: EC;
@@ -21,8 +21,9 @@ export interface EntitySidePanelProps<M extends Record<string, any> = any> {
21
21
  fullIdPath?: string;
22
22
  /**
23
23
  * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
24
- * Optional: when omitted the panel falls back to splitting `path`, which is correct for
25
- * any backend whose entity ids cannot contain "/".
24
+ *
25
+ * Optional, and never derived by splitting `path` — a guess would be wrong in exactly
26
+ * the case the field exists for. Absent means "not known here", not "no slashes".
26
27
  */
27
28
  pathSegments?: string[];
28
29
 
@@ -66,8 +66,32 @@ export interface FireCMSTranslations {
66
66
  select_from: string;
67
67
  done: string;
68
68
  log_out: string;
69
- license_needed: string;
70
- license_description: string;
69
+ /** License banner during the PRO trial. `{{days}}` is the number of days left; plural form. */
70
+ license_trial_banner: string;
71
+ /** `license_trial_banner` when one day is left (i18next `_one` plural form). */
72
+ license_trial_banner_one: string;
73
+ /** License banner once the trial has ended. `{{date}}` is the day it ended. */
74
+ license_expired_banner: string;
75
+ /** `license_expired_banner` when the end date is not known. */
76
+ license_expired_banner_undated: string;
77
+ /** License banner when the license key is not linked to this project. `{{projectId}}` is the Firebase project id. */
78
+ license_invalid_project_banner: string;
79
+ /** `license_invalid_project_banner` when the project id is not known. */
80
+ license_invalid_project_banner_unnamed: string;
81
+ /** License banner when more projects are linked than the license covers. `{{licensed}}` covered, `{{actual}}` linked; plural form. */
82
+ license_over_quota_banner: string;
83
+ /** `license_over_quota_banner` when the license covers one project (i18next `_one` plural form). */
84
+ license_over_quota_banner_one: string;
85
+ /** Link in the license banner to buy a license. */
86
+ license_get_license: string;
87
+ /** Link in the over-quota banner to the license settings. */
88
+ license_update_license: string;
89
+ /** Link in the invalid-project banner to link this project to the license. */
90
+ license_add_project_to_license: string;
91
+ /** Title of the notice a PRO view shows while PRO features are paused. */
92
+ pro_features_paused: string;
93
+ /** Users and Roles views while PRO features are paused. */
94
+ user_management_paused_description: string;
71
95
  column_cannot_be_edited: string;
72
96
  close: string;
73
97
  unsaved_local_changes: string;
@@ -470,6 +494,8 @@ export interface FireCMSTranslations {
470
494
  roles_menu: string;
471
495
  project_settings: string;
472
496
  firestore_manager: string;
497
+ /** The DataTalk product name, keyed so a locale can transliterate it. */
498
+ datatalk: string;
473
499
  manage_your_firestore_data: string;
474
500
 
475
501
  // ─── FireCMS Cloud Login ──────────────────────────────────────
@@ -486,6 +512,7 @@ export interface FireCMSTranslations {
486
512
  not_the_admin_panel: string;
487
513
  join_our_newsletter: string;
488
514
  by_signing_in_you_agree_to_our: string;
515
+ please_accept_the_terms_to_continue: string;
489
516
  terms_and_conditions: string;
490
517
  and_our: string;
491
518
  privacy_policy: string;
@@ -528,6 +555,8 @@ export interface FireCMSTranslations {
528
555
  licensed_projects_lowercase: string;
529
556
  manage: string;
530
557
  update_payment_method: string;
558
+ /** Snackbar when a Stripe billing portal link can't be created. `{{message}}` is the reason. */
559
+ stripe_portal_error: string;
531
560
  your_firecms_cloud_projects: string;
532
561
  status_active: string;
533
562
  status_trialing: string;
@@ -0,0 +1,168 @@
1
+ import { useEffect, useReducer } from "react";
2
+ import type { Locale as DateFnsLocale } from "date-fns";
3
+ import { Locale } from "../types";
4
+
5
+ /**
6
+ * On-demand loading of date-fns locales.
7
+ *
8
+ * `import * as locales from "date-fns/locale"` pulls in every locale date-fns
9
+ * ships — 625KB of parsed JavaScript, loaded on every single page view, to
10
+ * support a `locale` setting that is unset for almost every project. Worse, it
11
+ * sat on the critical path: `@firecms/core` imported it statically, so the
12
+ * browser could not render anything until it had come down.
13
+ *
14
+ * Each locale now has its own dynamic import, so exactly one small chunk is
15
+ * fetched, and only when a project actually configures a locale. Projects that
16
+ * do not configure one (the default) pay nothing at all.
17
+ *
18
+ * The map is written out rather than built from a template literal because Vite
19
+ * and webpack both need the specifier to be statically analysable to emit the
20
+ * chunks.
21
+ */
22
+ const loaders: Partial<Record<Locale, () => Promise<DateFnsLocale>>> = {
23
+ af: () => import("date-fns/locale/af").then(m => m.af),
24
+ ar: () => import("date-fns/locale/ar").then(m => m.ar),
25
+ arDZ: () => import("date-fns/locale/ar-DZ").then(m => m.arDZ),
26
+ arMA: () => import("date-fns/locale/ar-MA").then(m => m.arMA),
27
+ arSA: () => import("date-fns/locale/ar-SA").then(m => m.arSA),
28
+ az: () => import("date-fns/locale/az").then(m => m.az),
29
+ be: () => import("date-fns/locale/be").then(m => m.be),
30
+ bg: () => import("date-fns/locale/bg").then(m => m.bg),
31
+ bn: () => import("date-fns/locale/bn").then(m => m.bn),
32
+ ca: () => import("date-fns/locale/ca").then(m => m.ca),
33
+ cs: () => import("date-fns/locale/cs").then(m => m.cs),
34
+ cy: () => import("date-fns/locale/cy").then(m => m.cy),
35
+ da: () => import("date-fns/locale/da").then(m => m.da),
36
+ de: () => import("date-fns/locale/de").then(m => m.de),
37
+ el: () => import("date-fns/locale/el").then(m => m.el),
38
+ enAU: () => import("date-fns/locale/en-AU").then(m => m.enAU),
39
+ enCA: () => import("date-fns/locale/en-CA").then(m => m.enCA),
40
+ enGB: () => import("date-fns/locale/en-GB").then(m => m.enGB),
41
+ enIN: () => import("date-fns/locale/en-IN").then(m => m.enIN),
42
+ enNZ: () => import("date-fns/locale/en-NZ").then(m => m.enNZ),
43
+ enUS: () => import("date-fns/locale/en-US").then(m => m.enUS),
44
+ eo: () => import("date-fns/locale/eo").then(m => m.eo),
45
+ es: () => import("date-fns/locale/es").then(m => m.es),
46
+ et: () => import("date-fns/locale/et").then(m => m.et),
47
+ eu: () => import("date-fns/locale/eu").then(m => m.eu),
48
+ faIR: () => import("date-fns/locale/fa-IR").then(m => m.faIR),
49
+ fi: () => import("date-fns/locale/fi").then(m => m.fi),
50
+ fr: () => import("date-fns/locale/fr").then(m => m.fr),
51
+ frCA: () => import("date-fns/locale/fr-CA").then(m => m.frCA),
52
+ frCH: () => import("date-fns/locale/fr-CH").then(m => m.frCH),
53
+ gd: () => import("date-fns/locale/gd").then(m => m.gd),
54
+ gl: () => import("date-fns/locale/gl").then(m => m.gl),
55
+ gu: () => import("date-fns/locale/gu").then(m => m.gu),
56
+ he: () => import("date-fns/locale/he").then(m => m.he),
57
+ hi: () => import("date-fns/locale/hi").then(m => m.hi),
58
+ hr: () => import("date-fns/locale/hr").then(m => m.hr),
59
+ hu: () => import("date-fns/locale/hu").then(m => m.hu),
60
+ hy: () => import("date-fns/locale/hy").then(m => m.hy),
61
+ id: () => import("date-fns/locale/id").then(m => m.id),
62
+ is: () => import("date-fns/locale/is").then(m => m.is),
63
+ it: () => import("date-fns/locale/it").then(m => m.it),
64
+ ja: () => import("date-fns/locale/ja").then(m => m.ja),
65
+ ka: () => import("date-fns/locale/ka").then(m => m.ka),
66
+ kk: () => import("date-fns/locale/kk").then(m => m.kk),
67
+ kn: () => import("date-fns/locale/kn").then(m => m.kn),
68
+ ko: () => import("date-fns/locale/ko").then(m => m.ko),
69
+ lb: () => import("date-fns/locale/lb").then(m => m.lb),
70
+ lt: () => import("date-fns/locale/lt").then(m => m.lt),
71
+ lv: () => import("date-fns/locale/lv").then(m => m.lv),
72
+ mk: () => import("date-fns/locale/mk").then(m => m.mk),
73
+ ms: () => import("date-fns/locale/ms").then(m => m.ms),
74
+ mt: () => import("date-fns/locale/mt").then(m => m.mt),
75
+ nb: () => import("date-fns/locale/nb").then(m => m.nb),
76
+ nl: () => import("date-fns/locale/nl").then(m => m.nl),
77
+ nlBE: () => import("date-fns/locale/nl-BE").then(m => m.nlBE),
78
+ nn: () => import("date-fns/locale/nn").then(m => m.nn),
79
+ pl: () => import("date-fns/locale/pl").then(m => m.pl),
80
+ pt: () => import("date-fns/locale/pt").then(m => m.pt),
81
+ ptBR: () => import("date-fns/locale/pt-BR").then(m => m.ptBR),
82
+ ro: () => import("date-fns/locale/ro").then(m => m.ro),
83
+ ru: () => import("date-fns/locale/ru").then(m => m.ru),
84
+ sk: () => import("date-fns/locale/sk").then(m => m.sk),
85
+ sl: () => import("date-fns/locale/sl").then(m => m.sl),
86
+ sr: () => import("date-fns/locale/sr").then(m => m.sr),
87
+ srLatn: () => import("date-fns/locale/sr-Latn").then(m => m.srLatn),
88
+ sv: () => import("date-fns/locale/sv").then(m => m.sv),
89
+ ta: () => import("date-fns/locale/ta").then(m => m.ta),
90
+ te: () => import("date-fns/locale/te").then(m => m.te),
91
+ th: () => import("date-fns/locale/th").then(m => m.th),
92
+ tr: () => import("date-fns/locale/tr").then(m => m.tr),
93
+ ug: () => import("date-fns/locale/ug").then(m => m.ug),
94
+ uk: () => import("date-fns/locale/uk").then(m => m.uk),
95
+ uz: () => import("date-fns/locale/uz").then(m => m.uz),
96
+ vi: () => import("date-fns/locale/vi").then(m => m.vi),
97
+ zhCN: () => import("date-fns/locale/zh-CN").then(m => m.zhCN),
98
+ zhTW: () => import("date-fns/locale/zh-TW").then(m => m.zhTW),
99
+ };
100
+
101
+ const cache = new Map<Locale, DateFnsLocale>();
102
+ const inFlight = new Map<Locale, Promise<DateFnsLocale | undefined>>();
103
+
104
+ /**
105
+ * Load a date-fns locale, resolving from cache when it is already in memory.
106
+ * Concurrent callers for the same locale share one request.
107
+ */
108
+ export function loadDateFnsLocale(locale: Locale): Promise<DateFnsLocale | undefined> {
109
+ const cached = cache.get(locale);
110
+ if (cached) {
111
+ return Promise.resolve(cached);
112
+ }
113
+ const pending = inFlight.get(locale);
114
+ if (pending) {
115
+ return pending;
116
+ }
117
+ const loader = loaders[locale];
118
+ if (!loader) {
119
+ // An unknown key behaves as it did when the whole barrel was imported:
120
+ // the lookup missed and date-fns fell back to its built-in English.
121
+ return Promise.resolve(undefined);
122
+ }
123
+ const promise = loader()
124
+ .then((loaded) => {
125
+ cache.set(locale, loaded);
126
+ return loaded;
127
+ })
128
+ .catch(() => undefined)
129
+ .finally(() => {
130
+ inFlight.delete(locale);
131
+ });
132
+ inFlight.set(locale, promise);
133
+ return promise;
134
+ }
135
+
136
+ /** The locale if it is already loaded, otherwise undefined. Never suspends. */
137
+ export function getLoadedDateFnsLocale(locale?: Locale): DateFnsLocale | undefined {
138
+ return locale ? cache.get(locale) : undefined;
139
+ }
140
+
141
+ /**
142
+ * The date-fns locale for `locale`, loading it in the background on first use.
143
+ *
144
+ * Returns undefined until the chunk arrives, which formats the first paint with
145
+ * date-fns' built-in English and then re-renders. That is a change only for
146
+ * projects that set a locale, and only for the few hundred milliseconds before
147
+ * the chunk lands — in exchange for every project no longer waiting on 625KB.
148
+ */
149
+ export function useDateFnsLocale(locale?: Locale): DateFnsLocale | undefined {
150
+ const [, forceUpdate] = useReducer((count: number) => count + 1, 0);
151
+
152
+ useEffect(() => {
153
+ if (!locale || cache.has(locale)) {
154
+ return;
155
+ }
156
+ let active = true;
157
+ loadDateFnsLocale(locale).then((loaded) => {
158
+ if (active && loaded) {
159
+ forceUpdate();
160
+ }
161
+ });
162
+ return () => {
163
+ active = false;
164
+ };
165
+ }, [locale]);
166
+
167
+ return locale ? cache.get(locale) : undefined;
168
+ }
@@ -1,5 +1,23 @@
1
1
  import { EntityReference, GeoPoint, Vector } from "../types";
2
2
  import { isObject, isPlainObject } from "./objects";
3
+ import { encodeEntityId } from "./navigation_utils";
4
+
5
+ /**
6
+ * Key identifying one entity in a cache.
7
+ *
8
+ * The id is escaped so that a `(path, entityId)` pair maps to exactly one key. Without it
9
+ * `("a", "b/c")` and `("a/b", "c")` both flatten to `"a/b/c"`, and one entity's cached
10
+ * values are served for the other.
11
+ *
12
+ * `encodeEntityId` is the identity for any id without "/", "?", "#" or "%", so this changes
13
+ * no key in an app whose ids cannot contain them.
14
+ */
15
+ export function entityCacheKey(path: string | undefined, entityId: string | undefined): string {
16
+ // Either part may be undefined at a call site that used to build the key by string
17
+ // concatenation; both are stringified exactly as `path + "/" + entityId` did, so such a
18
+ // caller keeps the key it had.
19
+ return `${path}/${entityId === undefined ? entityId : encodeEntityId(entityId)}`;
20
+ }
3
21
 
4
22
  // Define a unique prefix for entity keys in localStorage to avoid key collisions
5
23
  const LOCAL_STORAGE_PREFIX = "entity_cache::";
package/src/util/index.ts CHANGED
@@ -28,3 +28,4 @@ export * from "./storage";
28
28
  export * from "./callbacks";
29
29
  export * from "./conditions";
30
30
  export * from "./lazy_eager";
31
+ export * from "./date_fns_locales";
@@ -1,9 +1,35 @@
1
1
  import React from "react";
2
2
 
3
3
  /**
4
- * Returns a React.lazy component that is also preloaded immediately using
5
- * requestIdleCallback or setTimeout.
6
- * This ensures that chunks are split, but fetched in the background before they are actually needed.
4
+ * Prefetch after the page has loaded, never during it.
5
+ *
6
+ * An idle callback scheduled at module evaluation time fires in the gaps between
7
+ * the tasks of the initial load — which is exactly when the critical chunks are
8
+ * still in flight. With a dozen route components each queueing one, the
9
+ * background prefetch was competing with the render path for connections and
10
+ * bandwidth. Waiting for the load event costs the prefetch nothing that matters:
11
+ * it exists to be ready before a navigation, not before first paint.
12
+ */
13
+ function schedulePrefetch(load: () => void) {
14
+ const whenIdle = () => {
15
+ if ("requestIdleCallback" in window) {
16
+ (window as any).requestIdleCallback(load);
17
+ } else {
18
+ setTimeout(load, 500);
19
+ }
20
+ };
21
+
22
+ if (document.readyState === "complete") {
23
+ whenIdle();
24
+ } else {
25
+ window.addEventListener("load", whenIdle, { once: true });
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Returns a React.lazy component that is also preloaded in the background, once
31
+ * the page has finished loading.
32
+ * This ensures that chunks are split, but fetched before they are actually needed.
7
33
  */
8
34
  export function lazyEager<T extends React.ComponentType<any>>(
9
35
  factory: () => Promise<any>,
@@ -22,11 +48,7 @@ export function lazyEager<T extends React.ComponentType<any>>(
22
48
  };
23
49
 
24
50
  if (typeof window !== "undefined") {
25
- if ("requestIdleCallback" in window) {
26
- (window as any).requestIdleCallback(load);
27
- } else {
28
- setTimeout(load, 500);
29
- }
51
+ schedulePrefetch(load);
30
52
  }
31
53
 
32
54
  return React.lazy(load);
@@ -147,7 +147,12 @@ export function getNavigationEntriesFromPath(props: {
147
147
  path: newPath,
148
148
  collections: collection.subcollections,
149
149
  currentFullPath: fullPath,
150
- currentFullIdPath: fullIdPath,
150
+ // The entity id is a hop in the id chain exactly as it is in the
151
+ // other two. Without it a nested `fullIdPath` was
152
+ // "products/locales" rather than "products/pid/locales", so any
153
+ // URL built from it pointed at a collection that does not exist.
154
+ // Escaped, because `fullIdPath` is URL-facing.
155
+ currentFullIdPath: fullIdPath + "/" + encodedEntityId,
151
156
  currentFullUrlPath: fullUrlPath,
152
157
  currentPathSegments: entitySegments,
153
158
  contextEntityViews: props.contextEntityViews