@vertesia/ui 1.5.0-dev.20260826.064249Z → 1.5.0-dev.20260901.033115Z

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 (130) hide show
  1. package/lib/core/components/FormItem.d.ts.map +1 -1
  2. package/lib/core/components/FormItem.js +2 -3
  3. package/lib/core/components/FormItem.js.map +1 -1
  4. package/lib/core/components/InfoTip.d.ts +20 -0
  5. package/lib/core/components/InfoTip.d.ts.map +1 -0
  6. package/lib/core/components/InfoTip.js +18 -0
  7. package/lib/core/components/InfoTip.js.map +1 -0
  8. package/lib/core/components/index.d.ts +1 -0
  9. package/lib/core/components/index.d.ts.map +1 -1
  10. package/lib/core/components/index.js +1 -0
  11. package/lib/core/components/index.js.map +1 -1
  12. package/lib/core/components/shadcn/Panel.d.ts.map +1 -1
  13. package/lib/core/components/shadcn/Panel.js +3 -3
  14. package/lib/core/components/shadcn/Panel.js.map +1 -1
  15. package/lib/features/store/objects/search/DocumentSearchContext.d.ts +15 -0
  16. package/lib/features/store/objects/search/DocumentSearchContext.d.ts.map +1 -1
  17. package/lib/features/store/objects/search/DocumentSearchContext.js +45 -2
  18. package/lib/features/store/objects/search/DocumentSearchContext.js.map +1 -1
  19. package/lib/features/store/objects/selection/actions/DeleteObjectsAction.d.ts.map +1 -1
  20. package/lib/features/store/objects/selection/actions/DeleteObjectsAction.js +5 -0
  21. package/lib/features/store/objects/selection/actions/DeleteObjectsAction.js.map +1 -1
  22. package/lib/features/store/types/IntakePolicyEditor.d.ts.map +1 -1
  23. package/lib/features/store/types/IntakePolicyEditor.js +1 -1
  24. package/lib/features/store/types/IntakePolicyEditor.js.map +1 -1
  25. package/lib/features/store/types/IntakePolicyForm.d.ts.map +1 -1
  26. package/lib/features/store/types/IntakePolicyForm.js +15 -15
  27. package/lib/features/store/types/IntakePolicyForm.js.map +1 -1
  28. package/lib/features/store/types/TypeRegistry.d.ts +10 -0
  29. package/lib/features/store/types/TypeRegistry.d.ts.map +1 -1
  30. package/lib/features/store/types/search/ObjectTypeSearchContext.d.ts +10 -0
  31. package/lib/features/store/types/search/ObjectTypeSearchContext.d.ts.map +1 -1
  32. package/lib/i18n/locales/ar.json +35 -0
  33. package/lib/i18n/locales/de.json +35 -0
  34. package/lib/i18n/locales/en.json +35 -0
  35. package/lib/i18n/locales/es.json +35 -0
  36. package/lib/i18n/locales/fr.json +35 -0
  37. package/lib/i18n/locales/it.json +35 -0
  38. package/lib/i18n/locales/ja.json +35 -0
  39. package/lib/i18n/locales/ko.json +35 -0
  40. package/lib/i18n/locales/pt.json +35 -0
  41. package/lib/i18n/locales/ru.json +35 -0
  42. package/lib/i18n/locales/tr.json +35 -0
  43. package/lib/i18n/locales/zh-TW.json +35 -0
  44. package/lib/i18n/locales/zh.json +35 -0
  45. package/lib/router/RouteComponent.d.ts +8 -0
  46. package/lib/router/RouteComponent.d.ts.map +1 -1
  47. package/lib/router/RouteComponent.js +41 -9
  48. package/lib/router/RouteComponent.js.map +1 -1
  49. package/lib/session/UserSessionProvider.d.ts.map +1 -1
  50. package/lib/session/UserSessionProvider.js +12 -13
  51. package/lib/session/UserSessionProvider.js.map +1 -1
  52. package/lib/session/auth/domainRouting.d.ts +11 -0
  53. package/lib/session/auth/domainRouting.d.ts.map +1 -1
  54. package/lib/session/auth/domainRouting.js +13 -0
  55. package/lib/session/auth/domainRouting.js.map +1 -1
  56. package/lib/session/useUXTracking.d.ts.map +1 -1
  57. package/lib/session/useUXTracking.js +6 -1
  58. package/lib/session/useUXTracking.js.map +1 -1
  59. package/lib/vertesia-ui-core.js +199 -4
  60. package/lib/vertesia-ui-core.js.map +1 -1
  61. package/lib/vertesia-ui-features.js +975 -33
  62. package/lib/vertesia-ui-features.js.map +1 -1
  63. package/lib/vertesia-ui-i18n.BJBGsq-8.chunk.js +5 -0
  64. package/lib/vertesia-ui-i18n.BJBGsq-8.chunk.js.map +1 -0
  65. package/lib/vertesia-ui-i18n.BY5YG5cX.chunk.js +5 -0
  66. package/lib/vertesia-ui-i18n.BY5YG5cX.chunk.js.map +1 -0
  67. package/lib/vertesia-ui-i18n.Bd5_1BXO.chunk.js +5 -0
  68. package/lib/vertesia-ui-i18n.Bd5_1BXO.chunk.js.map +1 -0
  69. package/lib/vertesia-ui-i18n.Bvwej2HH.chunk.js +5 -0
  70. package/lib/vertesia-ui-i18n.Bvwej2HH.chunk.js.map +1 -0
  71. package/lib/vertesia-ui-i18n.CMT3f4gf.chunk.js +5 -0
  72. package/lib/vertesia-ui-i18n.CMT3f4gf.chunk.js.map +1 -0
  73. package/lib/vertesia-ui-i18n.D56DNPJW.chunk.js +5 -0
  74. package/lib/vertesia-ui-i18n.D56DNPJW.chunk.js.map +1 -0
  75. package/lib/vertesia-ui-i18n.DDzKLNx6.chunk.js +5 -0
  76. package/lib/vertesia-ui-i18n.DDzKLNx6.chunk.js.map +1 -0
  77. package/lib/vertesia-ui-i18n.D_Am1LpF.chunk.js +5 -0
  78. package/lib/vertesia-ui-i18n.D_Am1LpF.chunk.js.map +1 -0
  79. package/lib/vertesia-ui-i18n.DrZizgGV.chunk.js +5 -0
  80. package/lib/vertesia-ui-i18n.DrZizgGV.chunk.js.map +1 -0
  81. package/lib/vertesia-ui-i18n.DwHsq86z.chunk.js +5 -0
  82. package/lib/vertesia-ui-i18n.DwHsq86z.chunk.js.map +1 -0
  83. package/lib/vertesia-ui-i18n.PLpo0Bbz.chunk.js +5 -0
  84. package/lib/vertesia-ui-i18n.PLpo0Bbz.chunk.js.map +1 -0
  85. package/lib/vertesia-ui-i18n.js +2 -38
  86. package/lib/vertesia-ui-i18n.js.map +1 -1
  87. package/lib/vertesia-ui-i18n.xVbXALqz.chunk.js +5 -0
  88. package/lib/vertesia-ui-i18n.xVbXALqz.chunk.js.map +1 -0
  89. package/lib/vertesia-ui-layout.js +80 -1
  90. package/lib/vertesia-ui-layout.js.map +1 -1
  91. package/lib/vertesia-ui-rich-text.js +278 -4
  92. package/lib/vertesia-ui-rich-text.js.map +1 -1
  93. package/lib/vertesia-ui-router.js +1 -1
  94. package/lib/vertesia-ui-router.js.map +1 -1
  95. package/lib/vertesia-ui-session.js +1 -1
  96. package/lib/vertesia-ui-session.js.map +1 -1
  97. package/lib/vertesia-ui-shell.js +118 -1
  98. package/lib/vertesia-ui-shell.js.map +1 -1
  99. package/lib/vertesia-ui-widgets.js +336 -20
  100. package/lib/vertesia-ui-widgets.js.map +1 -1
  101. package/package.json +7 -7
  102. package/src/core/components/FormItem.tsx +2 -5
  103. package/src/core/components/InfoTip.test.tsx +33 -0
  104. package/src/core/components/InfoTip.tsx +32 -0
  105. package/src/core/components/index.ts +1 -0
  106. package/src/core/components/shadcn/Panel.tsx +3 -7
  107. package/src/features/store/objects/search/DocumentSearchContext.test.ts +45 -0
  108. package/src/features/store/objects/search/DocumentSearchContext.ts +50 -2
  109. package/src/features/store/objects/selection/actions/DeleteObjectsAction.tsx +5 -0
  110. package/src/features/store/types/IntakePolicyEditor.tsx +3 -4
  111. package/src/features/store/types/IntakePolicyForm.tsx +120 -20
  112. package/src/i18n/locales/ar.json +35 -0
  113. package/src/i18n/locales/de.json +35 -0
  114. package/src/i18n/locales/en.json +35 -0
  115. package/src/i18n/locales/es.json +35 -0
  116. package/src/i18n/locales/fr.json +35 -0
  117. package/src/i18n/locales/it.json +35 -0
  118. package/src/i18n/locales/ja.json +35 -0
  119. package/src/i18n/locales/ko.json +35 -0
  120. package/src/i18n/locales/pt.json +35 -0
  121. package/src/i18n/locales/ru.json +35 -0
  122. package/src/i18n/locales/tr.json +35 -0
  123. package/src/i18n/locales/zh-TW.json +35 -0
  124. package/src/i18n/locales/zh.json +35 -0
  125. package/src/router/RouteComponent.test.tsx +139 -0
  126. package/src/router/RouteComponent.tsx +54 -9
  127. package/src/session/UserSessionProvider.tsx +12 -11
  128. package/src/session/auth/domainRouting.test.ts +24 -1
  129. package/src/session/auth/domainRouting.ts +29 -0
  130. package/src/session/useUXTracking.tsx +6 -1
@@ -1018,20 +1018,55 @@
1018
1018
  "intakePolicy.field.updateProperties": "更新属性",
1019
1019
  "intakePolicy.field.useVision": "使用视觉",
1020
1020
  "intakePolicy.field.windowPages": "每个窗口的页数",
1021
+ "intakePolicy.help.contactSheetDetail": "每张缩略图页数。页数越少,图块越大越易读。",
1022
+ "intakePolicy.help.coverageThreshold": "当某页的引用覆盖率低于此值时进行审核,这表明可能有内容被遗漏。",
1021
1023
  "intakePolicy.help.defaultView": "此类型对象首选的初始视图。",
1024
+ "intakePolicy.help.distinguishFrom": "容易与此类型混淆的其他类型。",
1022
1025
  "intakePolicy.help.embeddings": "可选的按类型文本、图像和属性嵌入开关。",
1023
1026
  "intakePolicy.help.examples": "此内容类型中经人工确认的示例对象 ID。",
1027
+ "intakePolicy.help.extractionInteraction": "用你自己的交互替代内置提取器。",
1028
+ "intakePolicy.help.extractionMaxPages": "进入提取环节的页数上限。",
1024
1029
  "intakePolicy.help.extractionSource": "选择用于提取的文本、视觉或组合证据。",
1030
+ "intakePolicy.help.forceOcr": "对每一页执行 OCR,包括已有文本层的页面。",
1031
+ "intakePolicy.help.forceReview": "无论难度分值如何,都审核每一份文档。",
1025
1032
  "intakePolicy.help.grounding": "为属性提取创建区块级引用和带批注的证据。",
1033
+ "intakePolicy.help.hardnessThreshold": "达到或超过该分值时改用难读内容模型。",
1026
1034
  "intakePolicy.help.identification": "提取前自动选择类型的说明。",
1027
1035
  "intakePolicy.help.identificationGuidance": "描述此内容类型及应选择它的情形。",
1028
1036
  "intakePolicy.help.locateInstructions": "设置此项可在转换和提取前识别相关页面。",
1037
+ "intakePolicy.help.locateMinPages": "文档短于此页数时跳过页面定位。",
1038
+ "intakePolicy.help.maxImageTokens": "真正起作用的预算:每次调用的预估图像令牌数。",
1039
+ "intakePolicy.help.maxPayloadMb": "这是传输上限,不是质量设置。",
1040
+ "intakePolicy.help.method": "文本来自内置解析、模型,还是你自己的交互。",
1041
+ "intakePolicy.help.minCitationDensity": "每个提取值的引用过少时,该轮会重试。",
1029
1042
  "intakePolicy.help.mode": "默认使用程序化采集;智能体采集将编排委派给智能体。",
1043
+ "intakePolicy.help.omitBlockBoxes": "从提示中去掉区块边框。仅在启用视觉时才安全。",
1030
1044
  "intakePolicy.help.pageRanges": "包含起始页和结束页。负值从文档末尾开始计数。",
1045
+ "intakePolicy.help.pageScope": "全部页面,或仅定位器选出的页面。",
1046
+ "intakePolicy.help.refreshOcr": "重新运行 OCR,而不是复用已存储的 OCR 结果。",
1047
+ "intakePolicy.help.renderDpi": "为模型渲染每一页时的分辨率。150 几乎适用于所有情况,仅在字迹极小时才调高。",
1031
1048
  "intakePolicy.help.renderingTemplate": "用于将提取属性呈现为对象文本的 Handlebars 模板。",
1032
1049
  "intakePolicy.help.review": "对困难或低覆盖率页面进行可选的模型审核。",
1050
+ "intakePolicy.help.section.classification": "Vertesia 如何识别此类型,以及其对象默认以何种方式打开。",
1051
+ "intakePolicy.help.section.conversion": "在提取任何内容之前,先把源文件转换为可读文本。",
1052
+ "intakePolicy.help.section.conversionModel": "负责将页面转为文本的模型,通常比提取模型更便宜。",
1053
+ "intakePolicy.help.section.customConversion": "用你自己的交互或智能体替代内置转换器。",
1054
+ "intakePolicy.help.section.embeddings": "哪些内容会被编入搜索索引,以及是否生成目录。",
1055
+ "intakePolicy.help.section.extraction": "此类型的架构属性如何从文档中填充。",
1056
+ "intakePolicy.help.section.extractionModel": "负责读取文档并填充属性的模型。",
1057
+ "intakePolicy.help.section.grounding": "将每个提取值追溯到其来源页面上的区块或视觉位置。",
1058
+ "intakePolicy.help.section.locate": "在正式处理前,用低成本的视觉扫描找出重要页面。",
1059
+ "intakePolicy.help.section.models": "根据文档页面的阅读难度选择使用的模型。",
1060
+ "intakePolicy.help.section.pageRanges": "要转换的固定页面。设置后优先于页面范围;负值从末尾开始计数。",
1061
+ "intakePolicy.help.section.rendering": "将提取的属性转换为对象正文的模板。",
1062
+ "intakePolicy.help.section.review": "对引用稀少或难以阅读的页面追加一轮处理。",
1063
+ "intakePolicy.help.section.visionBudget": "单次提取调用可携带的页面图像量。",
1033
1064
  "intakePolicy.help.textConversion": "控制提取和嵌入前的 Markdown 或文本生成。",
1034
1065
  "intakePolicy.help.title": "字段指南",
1066
+ "intakePolicy.help.updateProperties": "将带依据的结果写回对象属性。",
1067
+ "intakePolicy.help.useVision": "将带批注的页面图像与文本一起发送。",
1068
+ "intakePolicy.help.visionDetail": "此类型可使用的图像质量配置。配置本身由平台定义。",
1069
+ "intakePolicy.help.windowPages": "拆分前,单次依据调用处理的页数。",
1035
1070
  "intakePolicy.option.agentic": "智能体",
1036
1071
  "intakePolicy.option.all": "所有页面",
1037
1072
  "intakePolicy.option.auto": "自动",
@@ -0,0 +1,139 @@
1
+ import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
2
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3
+ import { prefetchLazyRoutes, RouteComponent } from './RouteComponent';
4
+ import { type LazyRouteModule, useNavigate } from './Router';
5
+ import { RouterProvider } from './RouterProvider';
6
+
7
+ /**
8
+ * Lazy routes resolve their module asynchronously, which opens two windows for showing the wrong
9
+ * page: reusing the state of the previous lazy route while the next import is in flight, and a
10
+ * slower earlier import landing after a newer navigation already resolved. Both were real —
11
+ * RouteComponent now remounts per path and drops stale module resolutions.
12
+ */
13
+
14
+ function deferredModule(text: string) {
15
+ let resolve!: (module: LazyRouteModule) => void;
16
+ const promise = new Promise<LazyRouteModule>((r) => {
17
+ resolve = r;
18
+ });
19
+ return {
20
+ promise,
21
+ resolve: () => resolve({ default: () => <div>{text}</div> }),
22
+ };
23
+ }
24
+
25
+ let navigateFn: ReturnType<typeof useNavigate>;
26
+ function CaptureNavigate() {
27
+ navigateFn = useNavigate();
28
+ return null;
29
+ }
30
+
31
+ describe('RouteComponent lazy routes', () => {
32
+ beforeEach(() => {
33
+ window.history.pushState({}, '', '/a');
34
+ });
35
+
36
+ afterEach(() => {
37
+ cleanup();
38
+ window.history.pushState({}, '', '/');
39
+ });
40
+
41
+ it('stops showing the previous lazy route while the next one loads', async () => {
42
+ const pageA = deferredModule('Page A');
43
+ const pageB = deferredModule('Page B');
44
+ const routes = [
45
+ { path: '/a', LazyComponent: () => pageA.promise },
46
+ { path: '/b', LazyComponent: () => pageB.promise },
47
+ ];
48
+ render(
49
+ <RouterProvider routes={routes}>
50
+ <CaptureNavigate />
51
+ <RouteComponent spinner={<div>loading</div>} />
52
+ </RouterProvider>,
53
+ );
54
+ await act(async () => pageA.resolve());
55
+ await waitFor(() => expect(screen.getByText('Page A')).toBeDefined());
56
+
57
+ await act(async () => navigateFn('/b'));
58
+ // The old page must not linger while B's import is still pending.
59
+ expect(screen.queryByText('Page A')).toBeNull();
60
+ expect(screen.getByText('loading')).toBeDefined();
61
+
62
+ await act(async () => pageB.resolve());
63
+ await waitFor(() => expect(screen.getByText('Page B')).toBeDefined());
64
+ });
65
+
66
+ it('ignores a slow import that resolves after a newer navigation', async () => {
67
+ const pageA = deferredModule('Page A');
68
+ const pageB = deferredModule('Page B');
69
+ const routes = [
70
+ { path: '/a', LazyComponent: () => pageA.promise },
71
+ { path: '/b', LazyComponent: () => pageB.promise },
72
+ ];
73
+ render(
74
+ <RouterProvider routes={routes}>
75
+ <CaptureNavigate />
76
+ <RouteComponent />
77
+ </RouterProvider>,
78
+ );
79
+ // Navigate away from /a before its import resolves.
80
+ await act(async () => navigateFn('/b'));
81
+ await act(async () => pageB.resolve());
82
+ await waitFor(() => expect(screen.getByText('Page B')).toBeDefined());
83
+
84
+ // A's import resolving late must not replace the current page.
85
+ await act(async () => pageA.resolve());
86
+ expect(screen.queryByText('Page A')).toBeNull();
87
+ expect(screen.getByText('Page B')).toBeDefined();
88
+ });
89
+
90
+ it('renders a previously resolved lazy route synchronously on revisit', async () => {
91
+ const pageA = deferredModule('Page A');
92
+ const pageB = deferredModule('Page B');
93
+ const routes = [
94
+ { path: '/a', LazyComponent: () => pageA.promise },
95
+ { path: '/b', LazyComponent: () => pageB.promise },
96
+ ];
97
+ render(
98
+ <RouterProvider routes={routes}>
99
+ <CaptureNavigate />
100
+ <RouteComponent spinner={<div>loading</div>} />
101
+ </RouterProvider>,
102
+ );
103
+ await act(async () => pageA.resolve());
104
+ await waitFor(() => expect(screen.getByText('Page A')).toBeDefined());
105
+ await act(async () => navigateFn('/b'));
106
+ await act(async () => pageB.resolve());
107
+ await waitFor(() => expect(screen.getByText('Page B')).toBeDefined());
108
+
109
+ // Back to /a: its module is cached, so not even one spinner frame may show.
110
+ act(() => navigateFn('/a'));
111
+ expect(screen.getByText('Page A')).toBeDefined();
112
+ expect(screen.queryByText('loading')).toBeNull();
113
+ });
114
+
115
+ it('prefetched lazy routes render without a spinner on first navigation', async () => {
116
+ const pageA = deferredModule('Page A');
117
+ const pageB = deferredModule('Page B');
118
+ const routes = [
119
+ { path: '/a', LazyComponent: () => pageA.promise },
120
+ { path: '/b', LazyComponent: () => pageB.promise },
121
+ { path: '/c', Component: () => <div>Page C</div> },
122
+ ];
123
+ pageA.resolve();
124
+ pageB.resolve();
125
+ await prefetchLazyRoutes(routes);
126
+
127
+ render(
128
+ <RouterProvider routes={routes}>
129
+ <CaptureNavigate />
130
+ <RouteComponent spinner={<div>loading</div>} />
131
+ </RouterProvider>,
132
+ );
133
+ // Even the initial mount renders from the prefetch cache.
134
+ expect(screen.getByText('Page A')).toBeDefined();
135
+ act(() => navigateFn('/b'));
136
+ expect(screen.getByText('Page B')).toBeDefined();
137
+ expect(screen.queryByText('loading')).toBeNull();
138
+ });
139
+ });
@@ -1,5 +1,41 @@
1
1
  import { useEffect, useState } from 'react';
2
- import { type ComponentRoute, type LazyComponentRoute, type LazyRouteModule, useRouterContext } from './Router';
2
+ import {
3
+ type ComponentRoute,
4
+ type LazyComponentRoute,
5
+ type LazyRouteModule,
6
+ type Route,
7
+ useRouterContext,
8
+ } from './Router';
9
+
10
+ // Resolved lazy modules, keyed by the route's LazyComponent function (a stable identity: route
11
+ // tables are declared once). A revisited lazy route renders synchronously from this cache instead
12
+ // of unmounting to the spinner for the async import() round-trip — without it, every navigation
13
+ // between two lazy routes blanks the page for at least a frame, even when the module is loaded.
14
+ const resolvedComponents = new Map<LazyComponentRoute['LazyComponent'], LazyRouteModule['default']>();
15
+
16
+ function resolveLazyRoute(route: LazyComponentRoute): Promise<LazyRouteModule['default']> {
17
+ return route.LazyComponent().then((module) => {
18
+ if (!module.default) {
19
+ throw new Error(`Lazy module for ${route.path} does not have a default export`);
20
+ }
21
+ resolvedComponents.set(route.LazyComponent, module.default);
22
+ return module.default;
23
+ });
24
+ }
25
+
26
+ /**
27
+ * Start loading the given routes' lazy modules ahead of navigation (non-lazy routes are ignored).
28
+ * Call it during idle time after first paint: the first navigation to a prefetched route then
29
+ * renders synchronously instead of flashing the spinner while its chunk downloads. Failures are
30
+ * swallowed — navigation retries the import and reports the error the normal way.
31
+ */
32
+ export function prefetchLazyRoutes(routes: Route[]): Promise<unknown> {
33
+ return Promise.allSettled(
34
+ routes
35
+ .filter((route): route is LazyComponentRoute => !!(route as LazyComponentRoute).LazyComponent)
36
+ .map((route) => resolveLazyRoute(route)),
37
+ );
38
+ }
3
39
 
4
40
  interface RouteComponentProps {
5
41
  spinner?: React.ReactNode;
@@ -12,7 +48,9 @@ export function RouteComponent({ spinner }: RouteComponentProps) {
12
48
  const Component = (route as ComponentRoute).Component;
13
49
  return <Component {...ctx.params} />;
14
50
  } else if ((route as LazyComponentRoute).LazyComponent) {
15
- return <LazyRouteComponent route={route as LazyComponentRoute} spinner={spinner} />;
51
+ // Keyed per path so navigating between two lazy routes remounts: a reused instance would
52
+ // keep rendering the previous route's component while the next import resolves.
53
+ return <LazyRouteComponent key={route.path} route={route as LazyComponentRoute} spinner={spinner} />;
16
54
  } else {
17
55
  throw new Error(`Invalid route for ${route.path}. Either Component or LazyCOmponent must be specified.`);
18
56
  }
@@ -23,16 +61,23 @@ interface LazyRouteComponentProps {
23
61
  spinner?: React.ReactNode;
24
62
  }
25
63
  function LazyRouteComponent({ route, spinner }: LazyRouteComponentProps) {
26
- const [Component, setComponent] = useState<LazyRouteModule['default'] | null>(null);
64
+ const [Component, setComponent] = useState<LazyRouteModule['default'] | null>(
65
+ () => resolvedComponents.get(route.LazyComponent) ?? null,
66
+ );
27
67
  useEffect(() => {
28
- void route.LazyComponent().then((module) => {
29
- if (!module.default) {
30
- throw new Error(`Lazy module for ${route.path} does not have a default export`);
68
+ // A slow import must not land after this instance moved on (unmount, or a route object
69
+ // swap without a path change): the stale module would overwrite the current one.
70
+ let stale = false;
71
+ void resolveLazyRoute(route).then((component) => {
72
+ if (!stale) {
73
+ // we need to wrap the component type in an arrow function
74
+ // otherwise the setState function will execute the function as a state update function
75
+ setComponent(() => component);
31
76
  }
32
- // we need to wrap the component type in an arrow function
33
- // otherwise the setState function will execute the function as a state update function
34
- setComponent(() => module.default);
35
77
  });
78
+ return () => {
79
+ stale = true;
80
+ };
36
81
  }, [route]);
37
82
 
38
83
  return Component ? <Component /> : spinner || null;
@@ -12,7 +12,7 @@ import {
12
12
  resolveAuthSelection,
13
13
  UserNotFoundError,
14
14
  } from './auth/composable';
15
- import { authReturnUrl, shouldRedirectToCentralAuth } from './auth/domainRouting';
15
+ import { authReturnUrl, centralAuthRedirectUrl, shouldRedirectToCentralAuth } from './auth/domainRouting';
16
16
  import { getFirebaseAuth } from './auth/firebase';
17
17
  import { useAuthState } from './auth/useAuthState';
18
18
  import { UserSession, UserSessionContext } from './UserSession';
@@ -110,13 +110,14 @@ export function UserSessionProvider({ children, loadOnboardingStatus = true }: U
110
110
  return true;
111
111
  };
112
112
 
113
- const redirectToCentralAuth = (projectId?: string, accountId?: string) => {
114
- const url = new URL(`${CENTRAL_AUTH_REDIRECT}?sts=${Env.endpoints.sts ?? 'https://sts.vertesia.io'}`);
115
- const currentUrl = authReturnUrl();
116
- if (projectId) currentUrl.searchParams.set('p', projectId);
117
- if (accountId) currentUrl.searchParams.set('a', accountId);
118
- url.searchParams.set('redirect_uri', currentUrl.toString());
119
- url.searchParams.set('state', generateState());
113
+ const redirectToCentralAuth = (selection: { accountId?: string; projectId?: string }) => {
114
+ const url = centralAuthRedirectUrl({
115
+ centralAuthUrl: CENTRAL_AUTH_REDIRECT,
116
+ stsEndpoint: Env.endpoints.sts ?? 'https://sts.vertesia.io',
117
+ returnUrl: authReturnUrl(),
118
+ state: generateState(),
119
+ ...selection,
120
+ });
120
121
  location.replace(url.toString());
121
122
  };
122
123
 
@@ -171,7 +172,7 @@ export function UserSessionProvider({ children, loadOnboardingStatus = true }: U
171
172
  state: state,
172
173
  },
173
174
  });
174
- redirectToCentralAuth();
175
+ redirectToCentralAuth({ accountId: selectedAccount, projectId: selectedProject });
175
176
  } else {
176
177
  clearState();
177
178
  }
@@ -191,7 +192,7 @@ export function UserSessionProvider({ children, loadOnboardingStatus = true }: U
191
192
  error: err,
192
193
  },
193
194
  });
194
- redirectToCentralAuth();
195
+ redirectToCentralAuth({ accountId: selectedAccount, projectId: selectedProject });
195
196
  });
196
197
  return;
197
198
  }
@@ -217,7 +218,7 @@ export function UserSessionProvider({ children, loadOnboardingStatus = true }: U
217
218
  project_id: selectedProject,
218
219
  },
219
220
  });
220
- redirectToCentralAuth();
221
+ redirectToCentralAuth({ accountId: selectedAccount, projectId: selectedProject });
221
222
  return; // Don't register onAuthStateChanged listener when redirecting
222
223
  }
223
224
 
@@ -1,5 +1,10 @@
1
1
  import { afterEach, describe, expect, it } from 'vitest';
2
- import { mountRootUrl, shouldRedirectToCentralAuth, shouldUseFirebaseAuth } from './domainRouting';
2
+ import {
3
+ centralAuthRedirectUrl,
4
+ mountRootUrl,
5
+ shouldRedirectToCentralAuth,
6
+ shouldUseFirebaseAuth,
7
+ } from './domainRouting';
3
8
 
4
9
  // Stub document.baseURI to simulate the served `<base href>` (deep gateway mount) or its absence.
5
10
  function setBaseURI(baseURI: string) {
@@ -30,6 +35,24 @@ describe('domainRouting', () => {
30
35
  expect(shouldRedirectToCentralAuth()).toBe(true);
31
36
  });
32
37
 
38
+ it('carries the selected account and project through central auth for a gateway-mounted app', () => {
39
+ const redirect = centralAuthRedirectUrl({
40
+ centralAuthUrl: 'https://internal-auth.vertesia.app/',
41
+ stsEndpoint: 'https://sts.example.test',
42
+ returnUrl: new URL('https://apps.example.test/tenants/t/apps/a/versions/v/app/?view=grid'),
43
+ state: 'state-1',
44
+ accountId: 'account-1',
45
+ projectId: 'project-1',
46
+ });
47
+ const returnUrl = new URL(redirect.searchParams.get('redirect_uri') || '');
48
+
49
+ expect(redirect.searchParams.get('sts')).toBe('https://sts.example.test');
50
+ expect(redirect.searchParams.get('state')).toBe('state-1');
51
+ expect(returnUrl.toString()).toBe(
52
+ 'https://apps.example.test/tenants/t/apps/a/versions/v/app/?view=grid&p=project-1&a=account-1',
53
+ );
54
+ });
55
+
33
56
  describe('mountRootUrl', () => {
34
57
  it('returns the deep gateway mount root (dropping any deep route / query / hash)', () => {
35
58
  const mount = 'https://gw.example.com/tenants/05948c_5ed5f4/apps/furniture-catalog/versions/v1/app/';
@@ -12,6 +12,35 @@ export function shouldRedirectToCentralAuth(_hostname?: string) {
12
12
  return !shouldUseFirebaseAuth();
13
13
  }
14
14
 
15
+ interface CentralAuthRedirectOptions {
16
+ centralAuthUrl: string;
17
+ stsEndpoint: string;
18
+ returnUrl: URL;
19
+ state: string;
20
+ accountId?: string;
21
+ projectId?: string;
22
+ }
23
+
24
+ /** Build a central-auth round trip without losing the app's requested account/project selection. */
25
+ export function centralAuthRedirectUrl({
26
+ centralAuthUrl,
27
+ stsEndpoint,
28
+ returnUrl,
29
+ state,
30
+ accountId,
31
+ projectId,
32
+ }: CentralAuthRedirectOptions): URL {
33
+ const selectedReturnUrl = new URL(returnUrl);
34
+ if (projectId) selectedReturnUrl.searchParams.set('p', projectId);
35
+ if (accountId) selectedReturnUrl.searchParams.set('a', accountId);
36
+
37
+ const url = new URL(centralAuthUrl);
38
+ url.searchParams.set('sts', stsEndpoint);
39
+ url.searchParams.set('redirect_uri', selectedReturnUrl.toString());
40
+ url.searchParams.set('state', state);
41
+ return url;
42
+ }
43
+
15
44
  /**
16
45
  * The page URL to return to after a central-auth round-trip (`redirect_uri`).
17
46
  *
@@ -23,7 +23,12 @@ export function useUXTracking() {
23
23
  console.debug('track event', eventName, eventProperties);
24
24
  }
25
25
 
26
- //GA via firebase
26
+ // GA via firebase — needs a complete Firebase config. A partial one (no apiKey, as in
27
+ // central-auth deployments and local setups) would make the Installations SDK log an
28
+ // async FirebaseError on every event, so analytics is a no-op there.
29
+ if (!Env.firebase?.apiKey) {
30
+ return;
31
+ }
27
32
  logEvent(getFirebaseAnalytics(), eventName, { ...eventProperties, debug_mode: !Env.isProd });
28
33
  };
29
34